[Security Solution][Host Isolation] Host Isolation Flyout UI only (#96077)

Co-authored-by: pzl <dan@panzarel.la>
This commit is contained in:
Candace Park 2021-04-28 11:02:53 -04:00 committed by GitHub
parent fb34ba0337
commit a1cb79b3d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 552 additions and 14 deletions

View file

@ -25,3 +25,6 @@ export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary';
export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`;
export const BASE_POLICY_ROUTE = `/api/endpoint/policy`;
export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`;
/** Host Isolation Routes */
export const HOST_ISOLATION_CREATE_API = `/api/endpoint/isolate`;

View file

@ -14,6 +14,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues;
const allowedExperimentalValues = Object.freeze({
trustedAppsByPolicyEnabled: false,
eventFilteringEnabled: false,
hostIsolationEnabled: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -42,6 +42,7 @@ export const mockGlobalState: State = {
enableExperimental: {
eventFilteringEnabled: false,
trustedAppsByPolicyEnabled: false,
hostIsolationEnabled: false,
},
},
hosts: {

View file

@ -0,0 +1,180 @@
/*
* 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 } from 'react';
import { find } from 'lodash/fp';
import {
EuiCallOut,
EuiTitle,
EuiText,
EuiTextArea,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiButtonEmpty,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useHostIsolation } from '../../containers/detection_engine/alerts/use_host_isolation';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
import {
CANCEL,
CASES_ASSOCIATED_WITH_ALERT,
COMMENT,
COMMENT_PLACEHOLDER,
CONFIRM,
RETURN_TO_ALERT_DETAILS,
} from './translations';
import { Maybe } from '../../../../../observability/common/typings';
export const HostIsolationPanel = React.memo(
({
details,
cancelCallback,
}: {
details: Maybe<TimelineEventsDetailsItem[]>;
cancelCallback: () => void;
}) => {
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] : '';
}, [details]);
const hostName = useMemo(() => {
const findHostName = find({ category: 'host', field: 'host.name' }, details)?.values;
return findHostName ? findHostName[0] : '';
}, [details]);
const alertRule = useMemo(() => {
const findAlertRule = find({ category: 'signal', field: 'signal.rule.name' }, details)
?.values;
return findAlertRule ? findAlertRule[0] : '';
}, [details]);
const { loading, isolateHost } = useHostIsolation({ agentId, comment });
const confirmHostIsolation = useCallback(async () => {
const hostIsolated = await isolateHost();
setIsIsolated(hostIsolated);
}, [isolateHost]);
const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]);
// a placeholder until we get the case count returned from a new case route in a future pr
const caseCount: number = 0;
const hostIsolated = useMemo(() => {
return (
<>
<EuiSpacer size="m" />
<EuiCallOut
iconType="check"
color="success"
title={i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.successfulIsolation.title',
{
defaultMessage: 'Host Isolation on {hostname} successfully submitted',
values: { hostname: hostName },
}
)}
>
{caseCount > 0 && (
<>
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolation.successfulIsolation.cases"
defaultMessage="This case has been attached to the following {caseCount, plural, one {case} other {cases}}:"
values={{ caseCount }}
/>
</p>
</EuiText>
<EuiText size="s">
<ul>
<li>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolation.placeholderCase"
defaultMessage="Case"
/>
</li>
</ul>
</EuiText>
</>
)}
</EuiCallOut>
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty flush="right" onClick={backToAlertDetails}>
<EuiText size="s">
<p>{RETURN_TO_ALERT_DETAILS}</p>
</EuiText>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}, [backToAlertDetails, hostName]);
const hostNotIsolated = useMemo(() => {
return (
<>
<EuiSpacer size="m" />
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolation.isolateThisHost"
defaultMessage="Isolate host {hostname} from network. This action will be added to the {cases}."
values={{
hostname: <b>{hostName}</b>,
cases: (
<b>
{caseCount}
{CASES_ASSOCIATED_WITH_ALERT}
{alertRule}
</b>
),
}}
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiTitle size="xs">
<h4>{COMMENT}</h4>
</EuiTitle>
<EuiTextArea
data-test-subj="host_isolation_comment"
fullWidth={true}
placeholder={COMMENT_PLACEHOLDER}
value={comment}
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
setComment(event.target.value)
}
/>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={backToAlertDetails}>{CANCEL}</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={confirmHostIsolation} isLoading={loading}>
{CONFIRM}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}, [alertRule, backToAlertDetails, comment, confirmHostIsolation, hostName, loading]);
return isIsolated ? hostIsolated : hostNotIsolated;
}
);
HostIsolationPanel.displayName = 'HostIsolationContent';

View file

@ -0,0 +1,65 @@
/*
* 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, { useState, useCallback, useMemo } from 'react';
import { EuiContextMenuItem, EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui';
import { ISOLATE_HOST } from './translations';
import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations';
export const TakeActionDropdown = React.memo(
({ onChange }: { onChange: (action: 'isolateHost') => void }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopoverHandler = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const takeActionItems = useMemo(() => {
return [
<EuiContextMenuItem
key="isolateHost"
onClick={() => {
setIsPopoverOpen(false);
onChange('isolateHost');
}}
>
{ISOLATE_HOST}
</EuiContextMenuItem>,
];
}, [onChange]);
const takeActionButton = useMemo(() => {
return (
<EuiButton
iconSide="right"
fill
iconType="arrowDown"
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
>
{TAKE_ACTION}
</EuiButton>
);
}, [isPopoverOpen]);
return (
<EuiPopover
id="hostIsolationTakeActionPanel"
button={takeActionButton}
isOpen={isPopoverOpen}
closePopover={closePopoverHandler}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={takeActionItems} />
</EuiPopover>
);
}
);
TakeActionDropdown.displayName = 'TakeActionDropdown';

View file

@ -0,0 +1,44 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ISOLATE_HOST = i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.isolateHost.isolateHost',
{
defaultMessage: 'Isolate host',
}
);
export const COMMENT = i18n.translate('xpack.securitySolution.endpoint.hostIsolation.comment', {
defaultMessage: 'Comment',
});
export const COMMENT_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.comment.placeholder',
{ defaultMessage: 'You may leave an optional note here.' }
);
export const CANCEL = i18n.translate('xpack.securitySolution.endpoint.hostIsolation.cancel', {
defaultMessage: 'Cancel',
});
export const CONFIRM = i18n.translate('xpack.securitySolution.endpoint.hostIsolation.confirm', {
defaultMessage: 'Confirm',
});
export const CASES_ASSOCIATED_WITH_ALERT = i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWihtAlert',
{
defaultMessage: ' cases associated with the rule ',
}
);
export const RETURN_TO_ALERT_DETAILS = i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.returnToAlertDetails',
{ defaultMessage: 'Return to alert details' }
);

View file

@ -12,6 +12,7 @@ import {
DETECTION_ENGINE_INDEX_URL,
DETECTION_ENGINE_PRIVILEGES_URL,
} from '../../../../../common/constants';
import { HOST_ISOLATION_CREATE_API } from '../../../../../common/endpoint/constants';
import { KibanaServices } from '../../../../common/lib/kibana';
import {
BasicSignals,
@ -20,6 +21,7 @@ import {
AlertSearchResponse,
AlertsIndex,
UpdateAlertStatusProps,
HostIsolationResponse,
} from './types';
/**
@ -101,3 +103,26 @@ export const createSignalIndex = async ({ signal }: BasicSignals): Promise<Alert
method: 'POST',
signal,
});
/**
* Get Host Isolation index
*
* @param agent id
* @param optional comment for the isolation action
*
* @throws An error if response is not OK
*/
export const createHostIsolation = async ({
agentId,
comment = '',
}: {
agentId: string;
comment?: string;
}): Promise<HostIsolationResponse> =>
KibanaServices.get().http.fetch<HostIsolationResponse>(HOST_ISOLATION_CREATE_API, {
method: 'POST',
body: JSON.stringify({
agent_ids: [agentId],
comment,
}),
});

View file

@ -34,3 +34,8 @@ export const SIGNAL_POST_FAILURE = i18n.translate(
defaultMessage: 'Failed to create signal index',
}
);
export const HOST_ISOLATION_FAILURE = i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.failedToIsolate.title',
{ defaultMessage: 'Failed to isolate host' }
);

View file

@ -48,6 +48,10 @@ export interface AlertsIndex {
index_mapping_outdated: boolean;
}
export interface HostIsolationResponse {
action: string;
}
export interface Privilege {
username: string;
has_all_requested: boolean;

View file

@ -0,0 +1,43 @@
/*
* 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 { createHostIsolation } from './api';
interface HostIsolationStatus {
loading: boolean;
isolateHost: () => Promise<boolean>;
}
interface UseHostIsolationProps {
agentId: string;
comment: string;
}
export const useHostIsolation = ({
agentId,
comment,
}: UseHostIsolationProps): HostIsolationStatus => {
const [loading, setLoading] = useState(false);
const { addError } = useAppToasts();
const isolateHost = useCallback(async () => {
try {
setLoading(true);
const isolationStatus = await createHostIsolation({ agentId, comment });
setLoading(false);
return isolationStatus.action ? true : false;
} catch (error) {
setLoading(false);
addError(error.message, { title: HOST_ISOLATION_FAILURE });
return false;
}
}, [agentId, comment, addError]);
return { loading, isolateHost };
};

View file

@ -5,16 +5,30 @@
* 2.0.
*/
import { some } from 'lodash/fp';
import { EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { find, some } from 'lodash/fp';
import {
EuiButtonEmpty,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiText,
} from '@elastic/eui';
import React, { useState, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import { BrowserFields, DocValueFields } from '../../../../common/containers/source';
import { ExpandableEvent, ExpandableEventTitle } from './expandable_event';
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 { ALERT_DETAILS } from './translations';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflow {
@ -56,8 +70,47 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
skip: !expandedEvent.eventId,
});
const isHostIsolationEnabled = useIsExperimentalFeatureEnabled('hostIsolationEnabled');
const [isHostIsolationPanelOpen, setIsHostIsolationPanel] = useState(false);
const showAlertDetails = useCallback(() => {
setIsHostIsolationPanel(false);
}, []);
const showHostIsolationPanel = useCallback((action) => {
if (action === 'isolateHost') {
setIsHostIsolationPanel(true);
}
}, []);
const isAlert = some({ category: 'signal', field: 'signal.rule.id' }, detailsData);
const isEndpointAlert = useMemo(() => {
const findEndpointAlert = find({ category: 'agent', field: 'agent.type' }, detailsData)?.values;
return findEndpointAlert ? findEndpointAlert[0] === 'endpoint' : false;
}, [detailsData]);
const backToAlertDetailsLink = useMemo(() => {
return (
<>
<EuiButtonEmpty
iconType="arrowLeft"
iconSide="left"
flush="left"
onClick={() => showAlertDetails()}
>
<EuiText size="xs">
<p>{ALERT_DETAILS}</p>
</EuiText>
</EuiButtonEmpty>
<EuiTitle>
<h2>{ISOLATE_HOST}</h2>
</EuiTitle>
</>
);
}, [showAlertDetails]);
if (!expandedEvent?.eventId) {
return null;
}
@ -65,19 +118,38 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
return isFlyoutView ? (
<>
<EuiFlyoutHeader hasBorder>
<ExpandableEventTitle isAlert={isAlert} loading={loading} />
{isHostIsolationPanelOpen ? (
backToAlertDetailsLink
) : (
<ExpandableEventTitle isAlert={isAlert} loading={loading} />
)}
</EuiFlyoutHeader>
<StyledEuiFlyoutBody>
<ExpandableEvent
browserFields={browserFields}
detailsData={detailsData}
event={expandedEvent}
isAlert={isAlert}
loading={loading}
timelineId={timelineId}
timelineTabType="flyout"
/>
{isHostIsolationPanelOpen ? (
<HostIsolationPanel details={detailsData} cancelCallback={showAlertDetails} />
) : (
<ExpandableEvent
browserFields={browserFields}
detailsData={detailsData}
event={expandedEvent}
isAlert={isAlert}
loading={loading}
timelineId={timelineId}
timelineTabType="flyout"
/>
)}
</StyledEuiFlyoutBody>
{isHostIsolationEnabled && isEndpointAlert && isHostIsolationPanelOpen === false && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<TakeActionDropdown onChange={showHostIsolationPanel} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiSpacer size="l" />
</EuiFlyoutFooter>
)}
</>
) : (
<>

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './isolation';

View file

@ -0,0 +1,85 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { IRouter } from 'src/core/server';
import { EndpointAppContext } from '../../types';
/**
* Registers the Host-(un-)isolation routes
*/
export function registerHostIsolationRoutes(router: IRouter, endpointContext: EndpointAppContext) {
// perform isolation
router.post(
{
path: `/api/endpoint/isolate`,
validate: {
body: schema.object({
agent_ids: schema.nullable(schema.arrayOf(schema.string())),
endpoint_ids: schema.nullable(schema.arrayOf(schema.string())),
alert_ids: schema.nullable(schema.arrayOf(schema.string())),
case_ids: schema.nullable(schema.arrayOf(schema.string())),
comment: schema.nullable(schema.string()),
}),
},
options: { authRequired: true },
},
async (context, req, res) => {
if (
(req.body.agent_ids === null || req.body.agent_ids.length === 0) &&
(req.body.endpoint_ids === null || req.body.endpoint_ids.length === 0)
) {
return res.badRequest({
body: {
message: 'At least one agent ID or endpoint ID is required',
},
});
}
return res.ok({
body: {
action: '713085d6-ab45-4e9e-b41d-96563cafdd97',
},
});
}
);
// perform UN-isolate
router.post(
{
path: `/api/endpoint/unisolate`,
validate: {
body: schema.object({
agent_ids: schema.nullable(schema.arrayOf(schema.string())),
endpoint_ids: schema.nullable(schema.arrayOf(schema.string())),
alert_ids: schema.nullable(schema.arrayOf(schema.string())),
case_ids: schema.nullable(schema.arrayOf(schema.string())),
comment: schema.nullable(schema.string()),
}),
},
options: { authRequired: true },
},
async (context, req, res) => {
if (
(req.body.agent_ids === null || req.body.agent_ids.length === 0) &&
(req.body.endpoint_ids === null || req.body.endpoint_ids.length === 0)
) {
return res.badRequest({
body: {
message: 'At least one agent ID or endpoint ID is required',
},
});
}
return res.ok({
body: {
action: '53ba1dd1-58a7-407e-b2a9-6843d9980068',
},
});
}
);
}

View file

@ -58,6 +58,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata';
import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency';
import { registerResolverRoutes } from './endpoint/routes/resolver';
import { registerPolicyRoutes } from './endpoint/routes/policy';
import { registerHostIsolationRoutes } from './endpoint/routes/actions';
import { EndpointArtifactClient, ManifestManager } from './endpoint/services';
import { EndpointAppContextService } from './endpoint/endpoint_app_context_services';
import { EndpointAppContext } from './endpoint/types';
@ -205,6 +206,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
registerResolverRoutes(router);
registerPolicyRoutes(router, endpointContext);
registerTrustedAppsRoutes(router, endpointContext);
registerHostIsolationRoutes(router, endpointContext);
plugins.features.registerKibanaFeature({
id: SERVER_APP_ID,