From fad75020d3e62e4920486d6a373e2e0e4a80217b Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 27 May 2021 11:26:45 -0400 Subject: [PATCH] [Security Solution][Endpoint][Host Isolation] User can unisolate host from alert details (#100401) (#100777) --- .../common/endpoint/actions.ts | 14 ++ .../common/endpoint/constants.ts | 3 + .../common/endpoint/types/index.ts | 2 +- .../endpoint/host_isolation/index.ts | 1 + .../host_isolation/isolate_success.tsx | 37 +++-- .../endpoint/host_isolation/translations.ts | 29 +++- .../host_isolation/unisolate_form.tsx | 81 ++++++++++ .../hooks/endpoint/use_isolate_privileges.ts | 40 +++++ .../components/host_isolation/index.tsx | 148 ++++++------------ .../components/host_isolation/isolate.tsx | 113 +++++++++++++ .../host_isolation/take_action_dropdown.tsx | 48 ++++-- .../components/host_isolation/translations.ts | 9 +- .../components/host_isolation/unisolate.tsx | 113 +++++++++++++ .../containers/detection_engine/alerts/api.ts | 45 +++++- .../detection_engine/alerts/translations.ts | 5 + .../alerts/use_host_isolation_status.tsx | 64 ++++++++ .../alerts/use_host_unisolation.tsx | 45 ++++++ .../pages/endpoint_hosts/store/middleware.ts | 15 +- .../side_panel/event_details/index.tsx | 52 ++++-- .../endpoint/routes/actions/isolation.ts | 9 +- .../endpoint/routes/metadata/handlers.ts | 4 +- 21 files changed, 714 insertions(+), 163 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/endpoint/actions.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx create mode 100644 x-pack/plugins/security_solution/public/common/hooks/endpoint/use_isolate_privileges.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_unisolation.tsx diff --git a/x-pack/plugins/security_solution/common/endpoint/actions.ts b/x-pack/plugins/security_solution/common/endpoint/actions.ts new file mode 100644 index 0000000000000..287ebddacad9a --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/actions.ts @@ -0,0 +1,14 @@ +/* + * 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 const userCanIsolate = (roles: readonly string[] | undefined): boolean => { + // only superusers can write to the fleet index (or look up endpoint data to convert endp ID to agent ID) + if (!roles || roles.length === 0) { + return false; + } + return roles.includes('superuser'); +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index a5718af1d42c5..1c0b09a4648e5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,6 +15,9 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; +export const HOST_METADATA_LIST_API = '/api/endpoint/metadata'; +export const HOST_METADATA_GET_API = '/api/endpoint/metadata/{id}'; + export const TRUSTED_APPS_GET_API = '/api/endpoint/trusted_apps/{id}'; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 512adffc70eef..dd0ff540cb4af 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -414,7 +414,7 @@ export type PolicyInfo = Immutable<{ id: string; }>; -export interface HostMetaDataInfo { +export interface HostMetadataInfo { metadata: HostMetadata; query_strategy_version: MetadataQueryStrategyVersions; } diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/index.ts b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/index.ts index de51df283251d..f5387a1b1a99c 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/index.ts @@ -7,3 +7,4 @@ export * from './isolate_success'; export * from './isolate_form'; +export * from './unisolate_form'; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx index 32ac1177d6e80..f822b3c287a02 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx @@ -7,27 +7,44 @@ import React, { memo, ReactNode } from 'react'; import { EuiButtonEmpty, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { GET_SUCCESS_MESSAGE } from './translations'; +import { GET_ISOLATION_SUCCESS_MESSAGE, GET_UNISOLATION_SUCCESS_MESSAGE } from './translations'; export interface EndpointIsolateSuccessProps { hostName: string; + isolateAction?: 'isolateHost' | 'unisolateHost'; completeButtonLabel: string; onComplete: () => void; additionalInfo?: ReactNode; } export const EndpointIsolateSuccess = memo( - ({ hostName, onComplete, completeButtonLabel, additionalInfo }) => { + ({ + hostName, + isolateAction = 'isolateHost', + onComplete, + completeButtonLabel, + additionalInfo, + }) => { return ( <> - - {additionalInfo} - + {isolateAction === 'isolateHost' ? ( + + {additionalInfo} + + ) : ( + + {additionalInfo} + + )} diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts index baeced2a7a69f..790c951f61ccd 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts @@ -24,8 +24,33 @@ export const COMMENT_PLACEHOLDER = i18n.translate( { defaultMessage: 'You may leave an optional note here.' } ); -export const GET_SUCCESS_MESSAGE = (hostName: string) => - i18n.translate('xpack.securitySolution.endpoint.hostIsolation.successfulMessage', { +export const GET_ISOLATION_SUCCESS_MESSAGE = (hostName: string) => + i18n.translate('xpack.securitySolution.endpoint.hostIsolation.isolation.successfulMessage', { defaultMessage: 'Host Isolation on {hostName} successfully submitted', values: { hostName }, }); + +export const GET_UNISOLATION_SUCCESS_MESSAGE = (hostName: string) => + i18n.translate('xpack.securitySolution.endpoint.hostIsolation.unisolate.successfulMessage', { + defaultMessage: 'Host Unisolation on {hostName} successfully submitted', + values: { hostName }, + }); + +export const ISOLATE = i18n.translate('xpack.securitySolution.endpoint.hostisolation.isolate', { + defaultMessage: 'isolate', +}); + +export const UNISOLATE = i18n.translate('xpack.securitySolution.endpoint.hostisolation.unisolate', { + defaultMessage: 'unisolate', +}); + +export const NOT_ISOLATED = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.notIsolated', + { + defaultMessage: 'not isolated', + } +); + +export const ISOLATED = i18n.translate('xpack.securitySolution.endpoint.hostIsolation.isolated', { + defaultMessage: 'isolated', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx new file mode 100644 index 0000000000000..98006524844c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx @@ -0,0 +1,81 @@ +/* + * 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, { ChangeEventHandler, memo, useCallback } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CANCEL, COMMENT, COMMENT_PLACEHOLDER, CONFIRM, UNISOLATE, ISOLATED } from './translations'; +import { EndpointIsolatedFormProps } from './isolate_form'; + +export const EndpointUnisolateForm = memo( + ({ hostName, onCancel, onConfirm, onChange, comment = '', messageAppend, isLoading = false }) => { + const handleCommentChange: ChangeEventHandler = useCallback( + (event) => { + onChange({ comment: event.target.value }); + }, + [onChange] + ); + + return ( + <> + +

+ {hostName}, + isolated: {ISOLATED}, + unisolate: {UNISOLATE}, + }} + />{' '} + {messageAppend} +

+
+ + + + +

{COMMENT}

+
+ + + + + + + + {CANCEL} + + + + + {CONFIRM} + + + + + ); + } +); + +EndpointUnisolateForm.displayName = 'EndpointUnisolateForm'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_isolate_privileges.ts b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_isolate_privileges.ts new file mode 100644 index 0000000000000..23ef6d586adc5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_isolate_privileges.ts @@ -0,0 +1,40 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { userCanIsolate } from '../../../../common/endpoint/actions'; +import { useKibana } from '../../lib/kibana'; +import { useLicense } from '../use_license'; + +interface IsolationPriviledgesStatus { + isLoading: boolean; + isAllowed: boolean; +} + +/* + * Host isolation requires superuser privileges and at least a platinum license + */ +export const useIsolationPrivileges = (): IsolationPriviledgesStatus => { + const [isLoading, setIsLoading] = useState(false); + const [canIsolate, setCanIsolate] = useState(false); + + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const services = useKibana().services; + + useEffect(() => { + setIsLoading(true); + const user = services.security.authc.getCurrentUser(); + if (user) { + user.then((authenticatedUser) => { + setCanIsolate(userCanIsolate(authenticatedUser.roles)); + setIsLoading(false); + }); + } + }, [services.security.authc]); + + return { isLoading, isAllowed: canIsolate && isPlatinumPlus ? true : false }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index e6fd3a8459f76..bb1585b5392bd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -5,33 +5,27 @@ * 2.0. */ -import React, { useMemo, useState, useCallback } from 'react'; +import React, { useMemo } from 'react'; import { find } from 'lodash/fp'; -import { EuiText, EuiSpacer } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useHostIsolation } from '../../containers/detection_engine/alerts/use_host_isolation'; -import { CASES_ASSOCIATED_WITH_ALERT, RETURN_TO_ALERT_DETAILS } from './translations'; import { Maybe } from '../../../../../observability/common/typings'; import { useCasesFromAlerts } from '../../containers/detection_engine/alerts/use_cases_from_alerts'; import { CaseDetailsLink } from '../../../common/components/links'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; -import { - EndpointIsolatedFormProps, - EndpointIsolateForm, - EndpointIsolateSuccess, -} from '../../../common/components/endpoint/host_isolation'; +import { IsolateHost } from './isolate'; +import { UnisolateHost } from './unisolate'; export const HostIsolationPanel = React.memo( ({ details, cancelCallback, + isolateAction, }: { details: Maybe; cancelCallback: () => void; + isolateAction: string; }) => { - const [comment, setComment] = useState(''); - const [isIsolated, setIsIsolated] = useState(false); - const agentId = useMemo(() => { const findAgentId = find({ category: 'agent', field: 'agent.id' }, details)?.values; return findAgentId ? findAgentId[0] : ''; @@ -54,25 +48,15 @@ export const HostIsolationPanel = React.memo( }, [details]); const { caseIds } = useCasesFromAlerts({ alertId }); - const { loading, isolateHost } = useHostIsolation({ agentId, comment, caseIds }); - - const confirmHostIsolation = useCallback(async () => { - const hostIsolated = await isolateHost(); - setIsIsolated(hostIsolated); - }, [isolateHost]); - - const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); - const handleIsolateFormChange: EndpointIsolatedFormProps['onChange'] = useCallback( - ({ comment: newComment }) => setComment(newComment), - [] - ); + // Cases related components to be used in both isolate and unisolate actions from the alert details flyout entry point + const caseCount: number = useMemo(() => caseIds.length, [caseIds]); const casesList = useMemo( () => caseIds.map((id, index) => { return ( -
  • +
  • caseIds.length, [caseIds]); - - const hostIsolated = useMemo(() => { - return ( - <> - - 0 && ( - <> - -

    - -

    -
    - -
      {casesList}
    -
    - - ) - } - /> - - ); - }, [backToAlertDetails, hostName, caseCount, casesList]); - - const hostNotIsolated = useMemo(() => { - return ( - <> - - - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), - }} - /> - } - /> - - ); - }, [ - hostName, - backToAlertDetails, - confirmHostIsolation, - handleIsolateFormChange, - comment, - loading, - caseCount, - alertRule, - ]); - - return isIsolated ? hostIsolated : hostNotIsolated; + const associatedCases = useMemo(() => { + if (caseCount > 0) { + return ( + <> + +

    + +

    +
    + +
      {casesList}
    +
    + + ); + } + }, [caseCount, casesList]); + + return isolateAction === 'isolateHost' ? ( + + ) : ( + + ); } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx new file mode 100644 index 0000000000000..10c9082976a0e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx @@ -0,0 +1,113 @@ +/* + * 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, useState, useCallback, ReactNode } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useHostIsolation } from '../../containers/detection_engine/alerts/use_host_isolation'; +import { CASES_ASSOCIATED_WITH_ALERT, RETURN_TO_ALERT_DETAILS } from './translations'; +import { + EndpointIsolatedFormProps, + EndpointIsolateForm, + EndpointIsolateSuccess, +} from '../../../common/components/endpoint/host_isolation'; + +export const IsolateHost = React.memo( + ({ + agentId, + hostName, + alertRule, + cases, + caseIds, + cancelCallback, + }: { + agentId: string; + hostName: string; + alertRule: string; + cases: ReactNode; + caseIds: string[]; + cancelCallback: () => void; + }) => { + const [comment, setComment] = useState(''); + const [isIsolated, setIsIsolated] = useState(false); + + const { loading, isolateHost } = useHostIsolation({ agentId, comment, caseIds }); + + const confirmHostIsolation = useCallback(async () => { + const hostIsolated = await isolateHost(); + setIsIsolated(hostIsolated); + }, [isolateHost]); + + const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); + + const handleIsolateFormChange: EndpointIsolatedFormProps['onChange'] = useCallback( + ({ comment: newComment }) => setComment(newComment), + [] + ); + + const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + + const hostIsolatedSuccess = useMemo(() => { + return ( + <> + + + + ); + }, [backToAlertDetails, hostName, cases]); + + const hostNotIsolated = useMemo(() => { + return ( + <> + + + {caseCount} + {CASES_ASSOCIATED_WITH_ALERT(caseCount)} + {alertRule} + + ), + }} + /> + } + /> + + ); + }, [ + hostName, + backToAlertDetails, + confirmHostIsolation, + handleIsolateFormChange, + comment, + loading, + caseCount, + alertRule, + ]); + + return isIsolated ? hostIsolatedSuccess : hostNotIsolated; + } +); + +IsolateHost.displayName = 'IsolateHost'; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx index 36f2553e1b927..a10ad901441ea 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx @@ -7,30 +7,33 @@ import React, { useState, useCallback, useMemo } from 'react'; import { EuiContextMenuItem, EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; -import { ISOLATE_HOST } from './translations'; +import { ISOLATE_HOST, UNISOLATE_HOST } from './translations'; import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations'; +import { useHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status'; export const TakeActionDropdown = React.memo( - ({ onChange }: { onChange: (action: 'isolateHost') => void }) => { + ({ + onChange, + agentId, + }: { + onChange: (action: 'isolateHost' | 'unisolateHost') => void; + agentId: string; + }) => { + const { loading, isIsolated: isolationStatus } = useHostIsolationStatus({ agentId }); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const closePopoverHandler = useCallback(() => { setIsPopoverOpen(false); }, []); - const takeActionItems = useMemo(() => { - return [ - { - setIsPopoverOpen(false); - onChange('isolateHost'); - }} - > - {ISOLATE_HOST} - , - ]; - }, [onChange]); + const isolateHostHandler = useCallback(() => { + setIsPopoverOpen(false); + if (isolationStatus === false) { + onChange('isolateHost'); + } else { + onChange('unisolateHost'); + } + }, [onChange, isolationStatus]); const takeActionButton = useMemo(() => { return ( @@ -38,6 +41,7 @@ export const TakeActionDropdown = React.memo( iconSide="right" fill iconType="arrowDown" + disabled={loading} onClick={() => { setIsPopoverOpen(!isPopoverOpen); }} @@ -45,7 +49,7 @@ export const TakeActionDropdown = React.memo( {TAKE_ACTION} ); - }, [isPopoverOpen]); + }, [isPopoverOpen, loading]); return ( - + + {isolationStatus === false ? ( + + {ISOLATE_HOST} + + ) : ( + + {UNISOLATE_HOST} + + )} + ); } diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 027a97cc3846e..449a09b932cd3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -8,12 +8,19 @@ import { i18n } from '@kbn/i18n'; export const ISOLATE_HOST = i18n.translate( - 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.isolateHost', + 'xpack.securitySolution.endpoint.hostIsolation.isolateHost', { defaultMessage: 'Isolate host', } ); +export const UNISOLATE_HOST = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.unisolateHost', + { + defaultMessage: 'Unisolate host', + } +); + export const CASES_ASSOCIATED_WITH_ALERT = (caseCount: number): string => i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWithAlert', diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx new file mode 100644 index 0000000000000..74149f2a692d3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -0,0 +1,113 @@ +/* + * 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, useState, useCallback, ReactNode } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CASES_ASSOCIATED_WITH_ALERT, RETURN_TO_ALERT_DETAILS } from './translations'; +import { + EndpointIsolatedFormProps, + EndpointIsolateSuccess, + EndpointUnisolateForm, +} from '../../../common/components/endpoint/host_isolation'; +import { useHostUnisolation } from '../../containers/detection_engine/alerts/use_host_unisolation'; + +export const UnisolateHost = React.memo( + ({ + agentId, + hostName, + alertRule, + cases, + caseIds, + cancelCallback, + }: { + agentId: string; + hostName: string; + alertRule: string; + cases: ReactNode; + caseIds: string[]; + cancelCallback: () => void; + }) => { + const [comment, setComment] = useState(''); + const [isUnIsolated, setIsUnIsolated] = useState(false); + + const { loading, unIsolateHost } = useHostUnisolation({ agentId, comment, caseIds }); + + const confirmHostUnIsolation = useCallback(async () => { + const hostIsolated = await unIsolateHost(); + setIsUnIsolated(hostIsolated); + }, [unIsolateHost]); + + const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); + + const handleIsolateFormChange: EndpointIsolatedFormProps['onChange'] = useCallback( + ({ comment: newComment }) => setComment(newComment), + [] + ); + + const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + + const hostUnisolatedSuccess = useMemo(() => { + return ( + <> + + + + ); + }, [backToAlertDetails, hostName, cases]); + + const hostNotUnisolated = useMemo(() => { + return ( + <> + + + {caseCount} + {CASES_ASSOCIATED_WITH_ALERT(caseCount)} + {alertRule} + + ), + }} + /> + } + /> + + ); + }, [ + hostName, + backToAlertDetails, + confirmHostUnIsolation, + handleIsolateFormChange, + comment, + loading, + caseCount, + alertRule, + ]); + + return isUnIsolated ? hostUnisolatedSuccess : hostNotUnisolated; + } +); + +UnisolateHost.displayName = 'UnisolateHost'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 28a6076421e83..65185b4d05135 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -7,13 +7,14 @@ import { UpdateDocumentByQueryResponse } from 'elasticsearch'; import { getCasesFromAlertsUrl } from '../../../../../../cases/common'; -import { HostIsolationResponse } from '../../../../../common/endpoint/types'; +import { HostIsolationResponse, HostMetadataInfo } from '../../../../../common/endpoint/types'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL, DETECTION_ENGINE_SIGNALS_STATUS_URL, DETECTION_ENGINE_INDEX_URL, DETECTION_ENGINE_PRIVILEGES_URL, } from '../../../../../common/constants'; +import { HOST_METADATA_GET_API } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; import { BasicSignals, @@ -24,7 +25,8 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { isolateHost } from '../../../../common/lib/host_isolation'; +import { resolvePathVariables } from '../../../../management/pages/trusted_apps/service/utils'; +import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; /** * Fetch Alerts by providing a query @@ -130,6 +132,30 @@ export const createHostIsolation = async ({ case_ids: caseIds, }); +/** + * Unisolate a host + * + * @param agent id + * @param optional comment for the unisolation action + * @param optional case ids if associated with an alert on the host + * + * @throws An error if response is not OK + */ +export const createHostUnIsolation = async ({ + agentId, + comment = '', + caseIds, +}: { + agentId: string; + comment?: string; + caseIds?: string[]; +}): Promise => + unIsolateHost({ + agent_ids: [agentId], + comment, + case_ids: caseIds, + }); + /** * Get list of associated case ids from alert id * @@ -143,3 +169,18 @@ export const getCaseIdsFromAlertId = async ({ KibanaServices.get().http.fetch(getCasesFromAlertsUrl(alertId), { method: 'get', }); + +/** + * Get Host metadata + * + * @param host id + */ +export const getHostMetadata = async ({ + agentId, +}: { + agentId: string; +}): Promise => + KibanaServices.get().http.fetch( + resolvePathVariables(HOST_METADATA_GET_API, { id: agentId }), + { method: 'get' } + ); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts index ed6a22375a776..9e4497f2f096b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts @@ -37,3 +37,8 @@ export const CASES_FROM_ALERTS_FAILURE = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title', { defaultMessage: 'Failed to find associated cases' } ); + +export const ISOLATION_STATUS_FAILURE = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.isolationStatus.title', + { defaultMessage: 'Failed to retrieve current isolation status' } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx new file mode 100644 index 0000000000000..adc6d3a6b054b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx @@ -0,0 +1,64 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { useEffect, useState } from 'react'; +import { Maybe } from '../../../../../../observability/common/typings'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { getHostMetadata } from './api'; +import { ISOLATION_STATUS_FAILURE } from './translations'; + +interface HostIsolationStatusResponse { + loading: boolean; + isIsolated: Maybe; +} + +/* + * Retrieves the current isolation status of a host */ +export const useHostIsolationStatus = ({ + agentId, +}: { + agentId: string; +}): HostIsolationStatusResponse => { + const [isIsolated, setIsIsolated] = useState>(); + const [loading, setLoading] = useState(false); + + const { addError } = useAppToasts(); + + useEffect(() => { + // isMounted tracks if a component is mounted before changing state + let isMounted = true; + const fetchData = async () => { + try { + const metadataResponse = await getHostMetadata({ agentId }); + if (isMounted) { + setIsIsolated(Boolean(metadataResponse.metadata.Endpoint.state.isolation)); + } + } catch (error) { + addError(error.message, { title: ISOLATION_STATUS_FAILURE }); + } + if (isMounted) { + setLoading(false); + } + }; + + setLoading((prevState) => { + if (prevState) { + return prevState; + } + if (!isEmpty(agentId)) { + fetchData(); + } + return true; + }); + return () => { + // updates to show component is unmounted + isMounted = false; + }; + }, [addError, agentId]); + return { loading, isIsolated }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_unisolation.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_unisolation.tsx new file mode 100644 index 0000000000000..1a0ecb0d15878 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_unisolation.tsx @@ -0,0 +1,45 @@ +/* + * 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, useState } from 'react'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { HOST_ISOLATION_FAILURE } from './translations'; +import { createHostUnIsolation } from './api'; + +interface HostUnisolationStatus { + loading: boolean; + unIsolateHost: () => Promise; +} + +interface UseHostIsolationProps { + agentId: string; + comment: string; + caseIds?: string[]; +} + +export const useHostUnisolation = ({ + agentId, + comment, + caseIds, +}: UseHostIsolationProps): HostUnisolationStatus => { + const [loading, setLoading] = useState(false); + const { addError } = useAppToasts(); + + const unIsolateHost = useCallback(async () => { + try { + setLoading(true); + const isolationStatus = await createHostUnIsolation({ agentId, comment, caseIds }); + setLoading(false); + return isolationStatus.action ? true : false; + } catch (error) { + setLoading(false); + addError(error.message, { title: HOST_ISOLATION_FAILURE }); + return false; + } + }, [agentId, comment, caseIds, addError]); + return { loading, unIsolateHost }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index ffde8b0931752..9db9932dd4387 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -36,8 +36,13 @@ import { sendGetFleetAgentsWithEndpoint, } from '../../policy/store/services/ingest'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; -import { metadataCurrentIndexPattern } from '../../../../../common/endpoint/constants'; +import { + HOST_METADATA_GET_API, + HOST_METADATA_LIST_API, + metadataCurrentIndexPattern, +} from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; +import { resolvePathVariables } from '../../trusted_apps/service/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -95,7 +100,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory('/api/endpoint/metadata', { + endpointResponse = await coreStart.http.post(HOST_METADATA_LIST_API, { body: JSON.stringify({ paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], filters: { kql: decodedQuery.query }, @@ -244,7 +249,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory( - `/api/endpoint/metadata/${selectedEndpoint}` + resolvePathVariables(HOST_METADATA_GET_API, { id: selectedEndpoint as string }) ); dispatch({ type: 'serverReturnedEndpointDetails', @@ -426,7 +431,7 @@ const getAgentAndPoliciesForEndpointsList = async ( const endpointsTotal = async (http: HttpStart): Promise => { try { return ( - await http.post('/api/endpoint/metadata', { + await http.post(HOST_METADATA_LIST_API, { body: JSON.stringify({ paging_properties: [{ page_index: 0 }, { page_size: 1 }], }), diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index de4795869f4b4..341397da09e1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -26,9 +26,13 @@ import { useTimelineEventsDetails } from '../../../containers/details'; import { TimelineTabs } from '../../../../../common/types/timeline'; import { HostIsolationPanel } from '../../../../detections/components/host_isolation'; import { TakeActionDropdown } from '../../../../detections/components/host_isolation/take_action_dropdown'; -import { ISOLATE_HOST } from '../../../../detections/components/host_isolation/translations'; +import { + ISOLATE_HOST, + UNISOLATE_HOST, +} from '../../../../detections/components/host_isolation/translations'; import { ALERT_DETAILS } from './translations'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useIsolationPrivileges } from '../../../../common/hooks/endpoint/use_isolate_privileges'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { @@ -74,13 +78,17 @@ const EventDetailsPanelComponent: React.FC = ({ const [isHostIsolationPanelOpen, setIsHostIsolationPanel] = useState(false); + const [isolateAction, setIsolateAction] = useState('isolateHost'); + const showAlertDetails = useCallback(() => { setIsHostIsolationPanel(false); }, []); + const { isAllowed: isIsolationAllowed } = useIsolationPrivileges(); const showHostIsolationPanel = useCallback((action) => { - if (action === 'isolateHost') { + if (action === 'isolateHost' || action === 'unisolateHost') { setIsHostIsolationPanel(true); + setIsolateAction(action); } }, []); @@ -91,6 +99,11 @@ const EventDetailsPanelComponent: React.FC = ({ return findEndpointAlert ? findEndpointAlert[0] === 'endpoint' : false; }, [detailsData]); + const agentId = useMemo(() => { + const findAgentId = find({ category: 'agent', field: 'agent.id' }, detailsData)?.values; + return findAgentId ? findAgentId[0] : ''; + }, [detailsData]); + const backToAlertDetailsLink = useMemo(() => { return ( <> @@ -105,11 +118,11 @@ const EventDetailsPanelComponent: React.FC = ({ -

    {ISOLATE_HOST}

    +

    {isolateAction === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST}

    ); - }, [showAlertDetails]); + }, [showAlertDetails, isolateAction]); if (!expandedEvent?.eventId) { return null; @@ -126,7 +139,11 @@ const EventDetailsPanelComponent: React.FC = ({ {isHostIsolationPanelOpen ? ( - + ) : ( = ({ /> )} - {isHostIsolationEnabled && isEndpointAlert && isHostIsolationPanelOpen === false && ( - - - - - - - - - - )} + {isIsolationAllowed && + isHostIsolationEnabled && + isEndpointAlert && + isHostIsolationPanelOpen === false && ( + + + + + + + + + + )} ) : ( <> diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 2471eef2bc14d..df6d23a2a2367 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -19,14 +19,7 @@ import { } from '../../../types'; import { getAgentIDsForEndpoints } from '../../services'; import { EndpointAppContext } from '../../types'; - -export const userCanIsolate = (roles: readonly string[] | undefined): boolean => { - // only superusers can write to the fleet index (or look up endpoint data to convert endp ID to agent ID) - if (!roles || roles.length === 0) { - return false; - } - return roles.includes('superuser'); -}; +import { userCanIsolate } from '../../../../common/endpoint/actions'; /** * Registers the Host-(un-)isolation routes diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 104383f398646..98610c2e84c02 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -17,7 +17,7 @@ import { import { HostInfo, HostMetadata, - HostMetaDataInfo, + HostMetadataInfo, HostResultList, HostStatus, MetadataQueryStrategyVersions, @@ -182,7 +182,7 @@ export async function getHostMetaData( metadataRequestContext: MetadataRequestContext, id: string, queryStrategyVersion?: MetadataQueryStrategyVersions -): Promise { +): Promise { if ( !metadataRequestContext.esClient && !metadataRequestContext.requestHandlerContext?.core.elasticsearch.client