[Security Solution][Endpoint][Host Isolation][Cases] Update Host Isolation comment in Cases UI (#102937)
This commit is contained in:
parent
f039f8311f
commit
e749fa62fa
|
@ -39,6 +39,7 @@ export enum CommentType {
|
|||
user = 'user',
|
||||
alert = 'alert',
|
||||
generatedAlert = 'generated_alert',
|
||||
actions = 'actions',
|
||||
}
|
||||
|
||||
export const ContextTypeUserRt = rt.type({
|
||||
|
@ -63,11 +64,38 @@ export const AlertCommentRequestRt = rt.type({
|
|||
owner: rt.string,
|
||||
});
|
||||
|
||||
export const ActionsCommentRequestRt = rt.type({
|
||||
type: rt.literal(CommentType.actions),
|
||||
comment: rt.string,
|
||||
actions: rt.type({
|
||||
targets: rt.array(
|
||||
rt.type({
|
||||
hostname: rt.string,
|
||||
endpointId: rt.string,
|
||||
})
|
||||
),
|
||||
type: rt.string,
|
||||
}),
|
||||
owner: rt.string,
|
||||
});
|
||||
|
||||
const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]);
|
||||
const AttributesTypeAlertsRt = rt.intersection([AlertCommentRequestRt, CommentAttributesBasicRt]);
|
||||
const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]);
|
||||
const AttributesTypeActionsRt = rt.intersection([
|
||||
ActionsCommentRequestRt,
|
||||
CommentAttributesBasicRt,
|
||||
]);
|
||||
const CommentAttributesRt = rt.union([
|
||||
AttributesTypeUserRt,
|
||||
AttributesTypeAlertsRt,
|
||||
AttributesTypeActionsRt,
|
||||
]);
|
||||
|
||||
export const CommentRequestRt = rt.union([ContextTypeUserRt, AlertCommentRequestRt]);
|
||||
export const CommentRequestRt = rt.union([
|
||||
ContextTypeUserRt,
|
||||
AlertCommentRequestRt,
|
||||
ActionsCommentRequestRt,
|
||||
]);
|
||||
|
||||
export const CommentResponseRt = rt.intersection([
|
||||
CommentAttributesRt,
|
||||
|
@ -85,6 +113,14 @@ export const CommentResponseTypeAlertsRt = rt.intersection([
|
|||
}),
|
||||
]);
|
||||
|
||||
export const CommentResponseTypeActionsRt = rt.intersection([
|
||||
AttributesTypeActionsRt,
|
||||
rt.type({
|
||||
id: rt.string,
|
||||
version: rt.string,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const AllCommentsResponseRT = rt.array(CommentResponseRt);
|
||||
|
||||
export const CommentPatchRequestRt = rt.intersection([
|
||||
|
@ -125,15 +161,18 @@ export const FindQueryParamsRt = rt.partial({
|
|||
});
|
||||
|
||||
export type FindQueryParams = rt.TypeOf<typeof FindQueryParamsRt>;
|
||||
export type AttributesTypeActions = rt.TypeOf<typeof AttributesTypeActionsRt>;
|
||||
export type AttributesTypeAlerts = rt.TypeOf<typeof AttributesTypeAlertsRt>;
|
||||
export type AttributesTypeUser = rt.TypeOf<typeof AttributesTypeUserRt>;
|
||||
export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>;
|
||||
export type CommentRequest = rt.TypeOf<typeof CommentRequestRt>;
|
||||
export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>;
|
||||
export type CommentResponseAlertsType = rt.TypeOf<typeof CommentResponseTypeAlertsRt>;
|
||||
export type CommentResponseActionsType = rt.TypeOf<typeof CommentResponseTypeActionsRt>;
|
||||
export type AllCommentsResponse = rt.TypeOf<typeof AllCommentsResponseRt>;
|
||||
export type CommentsResponse = rt.TypeOf<typeof CommentsResponseRt>;
|
||||
export type CommentPatchRequest = rt.TypeOf<typeof CommentPatchRequestRt>;
|
||||
export type CommentPatchAttributes = rt.TypeOf<typeof CommentPatchAttributesRt>;
|
||||
export type CommentRequestUserType = rt.TypeOf<typeof ContextTypeUserRt>;
|
||||
export type CommentRequestAlertType = rt.TypeOf<typeof AlertCommentRequestRt>;
|
||||
export type CommentRequestActionsType = rt.TypeOf<typeof ActionsCommentRequestRt>;
|
||||
|
|
|
@ -90,6 +90,10 @@ export const caseProps: CaseComponentProps = {
|
|||
},
|
||||
getCaseDetailHrefWithCommentId: jest.fn(),
|
||||
onComponentInitialized: jest.fn(),
|
||||
actionsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
ruleDetailsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
|
@ -408,6 +412,10 @@ describe('CaseView ', () => {
|
|||
},
|
||||
getCaseDetailHrefWithCommentId: jest.fn(),
|
||||
onComponentInitialized: jest.fn(),
|
||||
actionsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
ruleDetailsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
|
@ -448,6 +456,10 @@ describe('CaseView ', () => {
|
|||
},
|
||||
getCaseDetailHrefWithCommentId: jest.fn(),
|
||||
onComponentInitialized: jest.fn(),
|
||||
actionsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
ruleDetailsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
|
@ -485,6 +497,10 @@ describe('CaseView ', () => {
|
|||
},
|
||||
getCaseDetailHrefWithCommentId: jest.fn(),
|
||||
onComponentInitialized: jest.fn(),
|
||||
actionsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
ruleDetailsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
|
@ -522,6 +538,10 @@ describe('CaseView ', () => {
|
|||
},
|
||||
getCaseDetailHrefWithCommentId: jest.fn(),
|
||||
onComponentInitialized: jest.fn(),
|
||||
actionsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
ruleDetailsNavigation: {
|
||||
href: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
|
|
|
@ -50,6 +50,7 @@ export interface CaseViewComponentProps {
|
|||
configureCasesNavigation: CasesNavigation;
|
||||
getCaseDetailHrefWithCommentId: (commentId: string) => string;
|
||||
onComponentInitialized?: () => void;
|
||||
actionsNavigation?: CasesNavigation<string, 'configurable'>;
|
||||
ruleDetailsNavigation?: CasesNavigation<string | null | undefined, 'configurable'>;
|
||||
showAlertDetails?: (alertId: string, index: string) => void;
|
||||
subCaseId?: string;
|
||||
|
@ -99,6 +100,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
getCaseDetailHrefWithCommentId,
|
||||
fetchCase,
|
||||
onComponentInitialized,
|
||||
actionsNavigation,
|
||||
ruleDetailsNavigation,
|
||||
showAlertDetails,
|
||||
subCaseId,
|
||||
|
@ -418,6 +420,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
caseUserActions={caseUserActions}
|
||||
connectors={connectors}
|
||||
data={caseData}
|
||||
actionsNavigation={actionsNavigation}
|
||||
fetchUserActions={fetchCaseUserActions.bind(
|
||||
null,
|
||||
caseId,
|
||||
|
@ -505,6 +508,7 @@ export const CaseView = React.memo(
|
|||
getCaseDetailHrefWithCommentId,
|
||||
onCaseDataSuccess,
|
||||
onComponentInitialized,
|
||||
actionsNavigation,
|
||||
ruleDetailsNavigation,
|
||||
showAlertDetails,
|
||||
subCaseId,
|
||||
|
@ -543,6 +547,7 @@ export const CaseView = React.memo(
|
|||
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
|
||||
fetchCase={fetchCase}
|
||||
onComponentInitialized={onComponentInitialized}
|
||||
actionsNavigation={actionsNavigation}
|
||||
ruleDetailsNavigation={ruleDetailsNavigation}
|
||||
showAlertDetails={showAlertDetails}
|
||||
subCaseId={subCaseId}
|
||||
|
|
|
@ -7,12 +7,14 @@
|
|||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiCommentProps } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
CaseFullExternalService,
|
||||
ActionConnector,
|
||||
CaseStatuses,
|
||||
CommentType,
|
||||
Comment,
|
||||
CommentRequestActionsType,
|
||||
} from '../../../common';
|
||||
import { CaseUserActions } from '../../containers/types';
|
||||
import { CaseServices } from '../../containers/use_get_case_user_actions';
|
||||
|
@ -20,13 +22,16 @@ import { parseString } from '../../containers/utils';
|
|||
import { Tags } from '../tag_list/tags';
|
||||
import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar';
|
||||
import { UserActionTimestamp } from './user_action_timestamp';
|
||||
import { UserActionContentToolbar } from './user_action_content_toolbar';
|
||||
import { UserActionCopyLink } from './user_action_copy_link';
|
||||
import { UserActionMarkdown } from './user_action_markdown';
|
||||
import { UserActionMoveToReference } from './user_action_move_to_reference';
|
||||
import { Status, statuses } from '../status';
|
||||
import { UserActionShowAlert } from './user_action_show_alert';
|
||||
import * as i18n from './translations';
|
||||
import { AlertCommentEvent } from './user_action_alert_comment_event';
|
||||
import { CasesNavigation } from '../links';
|
||||
import { HostIsolationCommentEvent } from './user_action_host_isolation_comment_event';
|
||||
|
||||
interface LabelTitle {
|
||||
action: CaseUserActions;
|
||||
|
@ -34,6 +39,8 @@ interface LabelTitle {
|
|||
}
|
||||
export type RuleDetailsNavigation = CasesNavigation<string | null | undefined, 'configurable'>;
|
||||
|
||||
export type ActionsNavigation = CasesNavigation<string, 'configurable'>;
|
||||
|
||||
const getStatusTitle = (id: string, status: CaseStatuses) => (
|
||||
<EuiFlexGroup
|
||||
gutterSize="s"
|
||||
|
@ -350,6 +357,75 @@ export const getGeneratedAlertsAttachment = ({
|
|||
),
|
||||
});
|
||||
|
||||
export const getActionAttachment = ({
|
||||
comment,
|
||||
userCanCrud,
|
||||
isLoadingIds,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
actionsNavigation,
|
||||
manageMarkdownEditIds,
|
||||
handleManageMarkdownEditId,
|
||||
handleManageQuote,
|
||||
handleSaveComment,
|
||||
action,
|
||||
}: {
|
||||
comment: Comment & CommentRequestActionsType;
|
||||
userCanCrud: boolean;
|
||||
isLoadingIds: string[];
|
||||
getCaseDetailHrefWithCommentId: (commentId: string) => string;
|
||||
actionsNavigation?: ActionsNavigation;
|
||||
manageMarkdownEditIds: string[];
|
||||
handleManageMarkdownEditId: (id: string) => void;
|
||||
handleManageQuote: (id: string) => void;
|
||||
handleSaveComment: ({ id, version }: { id: string; version: string }, content: string) => void;
|
||||
action: CaseUserActions;
|
||||
}): EuiCommentProps => ({
|
||||
username: (
|
||||
<UserActionUsernameWithAvatar
|
||||
username={comment.createdBy.username}
|
||||
fullName={comment.createdBy.fullName}
|
||||
/>
|
||||
),
|
||||
className: classNames({
|
||||
isEdit: manageMarkdownEditIds.includes(comment.id),
|
||||
}),
|
||||
event: (
|
||||
<HostIsolationCommentEvent
|
||||
type={comment.actions.type}
|
||||
endpoints={comment.actions.targets}
|
||||
href={actionsNavigation?.href}
|
||||
onClick={actionsNavigation?.onClick}
|
||||
/>
|
||||
),
|
||||
'data-test-subj': 'endpoint-action',
|
||||
timestamp: <UserActionTimestamp createdAt={action.actionAt} />,
|
||||
timelineIcon: comment.actions.type === 'isolate' ? 'lock' : 'lockOpen',
|
||||
actions: (
|
||||
<UserActionContentToolbar
|
||||
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
|
||||
id={comment.id}
|
||||
editLabel={i18n.EDIT_COMMENT}
|
||||
quoteLabel={i18n.QUOTE}
|
||||
userCanCrud={userCanCrud}
|
||||
isLoading={isLoadingIds.includes(comment.id)}
|
||||
onEdit={handleManageMarkdownEditId.bind(null, comment.id)}
|
||||
onQuote={handleManageQuote.bind(null, comment.comment)}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<UserActionMarkdown
|
||||
id={comment.id}
|
||||
content={comment.comment}
|
||||
isEditable={manageMarkdownEditIds.includes(comment.id)}
|
||||
onChangeEditable={handleManageMarkdownEditId}
|
||||
onSaveContent={handleSaveComment.bind(null, {
|
||||
id: comment.id,
|
||||
version: comment.version,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
interface Signal {
|
||||
rule: {
|
||||
id: string;
|
||||
|
|
|
@ -28,6 +28,7 @@ const defaultProps = {
|
|||
caseUserActions: [],
|
||||
connectors: [],
|
||||
getCaseDetailHrefWithCommentId: jest.fn(),
|
||||
actionsNavigation: { href: jest.fn(), onClick: jest.fn() },
|
||||
getRuleDetailsHref: jest.fn(),
|
||||
onRuleDetailsClick: jest.fn(),
|
||||
data: basicCase,
|
||||
|
|
|
@ -26,6 +26,7 @@ import { useCurrentUser } from '../../common/lib/kibana';
|
|||
import { AddComment, AddCommentRefObject } from '../add_comment';
|
||||
import {
|
||||
ActionConnector,
|
||||
ActionsCommentRequestRt,
|
||||
AlertCommentRequestRt,
|
||||
Case,
|
||||
CaseUserActions,
|
||||
|
@ -45,6 +46,8 @@ import {
|
|||
getAlertAttachment,
|
||||
getGeneratedAlertsAttachment,
|
||||
RuleDetailsNavigation,
|
||||
ActionsNavigation,
|
||||
getActionAttachment,
|
||||
} from './helpers';
|
||||
import { UserActionAvatar } from './user_action_avatar';
|
||||
import { UserActionMarkdown } from './user_action_markdown';
|
||||
|
@ -61,6 +64,7 @@ export interface UserActionTreeProps {
|
|||
fetchUserActions: () => void;
|
||||
getCaseDetailHrefWithCommentId: (commentId: string) => string;
|
||||
getRuleDetailsHref?: RuleDetailsNavigation['href'];
|
||||
actionsNavigation?: ActionsNavigation;
|
||||
isLoadingDescription: boolean;
|
||||
isLoadingUserActions: boolean;
|
||||
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
|
||||
|
@ -125,6 +129,7 @@ export const UserActionTree = React.memo(
|
|||
fetchUserActions,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
getRuleDetailsHref,
|
||||
actionsNavigation,
|
||||
isLoadingDescription,
|
||||
isLoadingUserActions,
|
||||
onRuleDetailsClick,
|
||||
|
@ -447,6 +452,30 @@ export const UserActionTree = React.memo(
|
|||
]
|
||||
: []),
|
||||
];
|
||||
} else if (
|
||||
comment != null &&
|
||||
isRight(ActionsCommentRequestRt.decode(comment)) &&
|
||||
comment.type === CommentType.actions
|
||||
) {
|
||||
return [
|
||||
...comments,
|
||||
...(comment.actions !== null
|
||||
? [
|
||||
getActionAttachment({
|
||||
comment,
|
||||
userCanCrud,
|
||||
isLoadingIds,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
actionsNavigation,
|
||||
manageMarkdownEditIds,
|
||||
handleManageMarkdownEditId,
|
||||
handleManageQuote,
|
||||
handleSaveComment,
|
||||
action,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -559,6 +588,7 @@ export const UserActionTree = React.memo(
|
|||
handleManageMarkdownEditId,
|
||||
handleSaveComment,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
actionsNavigation,
|
||||
userCanCrud,
|
||||
isLoadingIds,
|
||||
handleManageQuote,
|
||||
|
|
|
@ -56,3 +56,17 @@ export const SHOW_ALERT_TOOLTIP = i18n.translate('xpack.cases.caseView.showAlert
|
|||
export const UNKNOWN_RULE = i18n.translate('xpack.cases.caseView.unknownRule.label', {
|
||||
defaultMessage: 'Unknown rule',
|
||||
});
|
||||
|
||||
export const ISOLATED_HOST = i18n.translate('xpack.cases.caseView.isolatedHost', {
|
||||
defaultMessage: 'isolated host',
|
||||
});
|
||||
|
||||
export const RELEASED_HOST = i18n.translate('xpack.cases.caseView.releasedHost', {
|
||||
defaultMessage: 'released host',
|
||||
});
|
||||
|
||||
export const OTHER_ENDPOINTS = (endpoints: number): string =>
|
||||
i18n.translate('xpack.cases.caseView.otherEndpoints', {
|
||||
values: { endpoints },
|
||||
defaultMessage: ` and {endpoints} {endpoints, plural, =1 {other} other {others}}`,
|
||||
});
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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, { memo, useCallback } from 'react';
|
||||
import * as i18n from './translations';
|
||||
import { LinkAnchor } from '../links';
|
||||
import { ActionsNavigation } from './helpers';
|
||||
|
||||
interface EndpointInfo {
|
||||
endpointId: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
endpoints: EndpointInfo[];
|
||||
href?: ActionsNavigation['href'];
|
||||
onClick?: ActionsNavigation['onClick'];
|
||||
}
|
||||
|
||||
const HostIsolationCommentEventComponent: React.FC<Props> = ({
|
||||
type,
|
||||
endpoints,
|
||||
href,
|
||||
onClick,
|
||||
}) => {
|
||||
const endpointDetailsHref = href ? href(endpoints[0].endpointId) : '';
|
||||
|
||||
const onLinkClick = useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
if (onClick) onClick(endpoints[0].endpointId, ev);
|
||||
},
|
||||
[onClick, endpoints]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{type === 'isolate' ? `${i18n.ISOLATED_HOST} ` : `${i18n.RELEASED_HOST} `}
|
||||
<LinkAnchor
|
||||
onClick={onLinkClick}
|
||||
href={endpointDetailsHref}
|
||||
data-test-subj={`endpointDetails-activity-log-link-${endpoints[0].endpointId}`}
|
||||
>
|
||||
{endpoints[0].hostname}
|
||||
</LinkAnchor>
|
||||
{endpoints.length > 1 && i18n.OTHER_ENDPOINTS(endpoints.length - 1)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostIsolationCommentEvent = memo(HostIsolationCommentEventComponent);
|
|
@ -17,6 +17,7 @@ import { nodeBuilder, KueryNode } from '../../../../../src/plugins/data/common';
|
|||
import { esKuery } from '../../../../../src/plugins/data/server';
|
||||
import {
|
||||
AlertCommentRequestRt,
|
||||
ActionsCommentRequestRt,
|
||||
CASE_SAVED_OBJECT,
|
||||
CaseConnector,
|
||||
CaseStatuses,
|
||||
|
@ -35,12 +36,15 @@ import {
|
|||
getIDsAndIndicesAsArrays,
|
||||
isCommentRequestTypeAlertOrGenAlert,
|
||||
isCommentRequestTypeUser,
|
||||
isCommentRequestTypeActions,
|
||||
SavedObjectFindOptionsKueryNode,
|
||||
} from '../common';
|
||||
|
||||
export const decodeCommentRequest = (comment: CommentRequest) => {
|
||||
if (isCommentRequestTypeUser(comment)) {
|
||||
pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity));
|
||||
} else if (isCommentRequestTypeActions(comment)) {
|
||||
pipe(excess(ActionsCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity));
|
||||
} else if (isCommentRequestTypeAlertOrGenAlert(comment)) {
|
||||
pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity));
|
||||
const { ids, indices } = getIDsAndIndicesAsArrays(comment);
|
||||
|
|
|
@ -317,6 +317,15 @@ export const isCommentRequestTypeUser = (
|
|||
return context.type === CommentType.user;
|
||||
};
|
||||
|
||||
/**
|
||||
* A type narrowing function for actions comments. Exporting so integration tests can use it.
|
||||
*/
|
||||
export const isCommentRequestTypeActions = (
|
||||
context: CommentRequest
|
||||
): context is CommentRequestUserType => {
|
||||
return context.type === CommentType.actions;
|
||||
};
|
||||
|
||||
/**
|
||||
* A type narrowing function for alert comments. Exporting so integration tests can use it.
|
||||
*/
|
||||
|
|
|
@ -27,6 +27,18 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
|
|||
type: {
|
||||
type: 'keyword',
|
||||
},
|
||||
actions: {
|
||||
properties: {
|
||||
targets: {
|
||||
type: 'nested',
|
||||
properties: {
|
||||
hostname: { type: 'keyword' },
|
||||
endpointId: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
type: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
alertId: {
|
||||
type: 'keyword',
|
||||
},
|
||||
|
|
|
@ -9,10 +9,8 @@ import { schema, TypeOf } from '@kbn/config-schema';
|
|||
|
||||
export const HostIsolationRequestSchema = {
|
||||
body: schema.object({
|
||||
/** A list of Fleet Agent IDs whose hosts will be isolated */
|
||||
agent_ids: schema.maybe(schema.arrayOf(schema.string())),
|
||||
/** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */
|
||||
endpoint_ids: schema.maybe(schema.arrayOf(schema.string())),
|
||||
endpoint_ids: schema.arrayOf(schema.string(), { minSize: 1 }),
|
||||
/** If defined, any case associated with the given IDs will be updated */
|
||||
alert_ids: schema.maybe(schema.arrayOf(schema.string())),
|
||||
/** Case IDs to be updated */
|
||||
|
|
|
@ -35,6 +35,7 @@ import { useInsertTimeline } from '../use_insert_timeline';
|
|||
import { SpyRoute } from '../../../common/utils/route/spy_routes';
|
||||
import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline';
|
||||
import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context';
|
||||
import { getEndpointDetailsPath } from '../../../management/common/routing';
|
||||
|
||||
interface Props {
|
||||
caseId: string;
|
||||
|
@ -162,6 +163,14 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
|
|||
[dispatch]
|
||||
);
|
||||
|
||||
const endpointDetailsHref = (endpointId: string) =>
|
||||
formatUrl(
|
||||
getEndpointDetailsPath({
|
||||
name: 'endpointActivityLog',
|
||||
selected_endpoint: endpointId,
|
||||
})
|
||||
);
|
||||
|
||||
const onComponentInitialized = useCallback(() => {
|
||||
dispatch(
|
||||
timelineActions.createTimeline({
|
||||
|
@ -220,6 +229,20 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
|
|||
getCaseDetailHrefWithCommentId,
|
||||
onCaseDataSuccess,
|
||||
onComponentInitialized,
|
||||
actionsNavigation: {
|
||||
href: endpointDetailsHref,
|
||||
onClick: (endpointId: string, e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return navigateToApp(APP_ID, {
|
||||
path: getEndpointDetailsPath({
|
||||
name: 'endpointActivityLog',
|
||||
selected_endpoint: endpointId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
ruleDetailsNavigation: {
|
||||
href: getDetectionsRuleDetailsHref,
|
||||
onClick: async (ruleId: string | null | undefined, e) => {
|
||||
|
|
|
@ -14,7 +14,6 @@ import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/end
|
|||
|
||||
export const hostIsolationRequestBodyMock = (): HostIsolationRequestBody => {
|
||||
return {
|
||||
agent_ids: ['fd8a122b-4c54-4c05-b295-111'],
|
||||
endpoint_ids: ['88c04a90-b19c-11eb-b838-222'],
|
||||
alert_ids: ['88c04a90-b19c-11eb-b838-333'],
|
||||
case_ids: ['88c04a90-b19c-11eb-b838-444'],
|
||||
|
|
|
@ -66,7 +66,12 @@ export const getEndpointListPath = (
|
|||
|
||||
export const getEndpointDetailsPath = (
|
||||
props: {
|
||||
name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate' | 'endpointUnIsolate';
|
||||
name:
|
||||
| 'endpointDetails'
|
||||
| 'endpointPolicyResponse'
|
||||
| 'endpointIsolate'
|
||||
| 'endpointUnIsolate'
|
||||
| 'endpointActivityLog';
|
||||
} & EndpointIndexUIQueryParams &
|
||||
EndpointDetailsUrlProps,
|
||||
search?: string
|
||||
|
@ -85,6 +90,9 @@ export const getEndpointDetailsPath = (
|
|||
case 'endpointPolicyResponse':
|
||||
queryParams.show = 'policy_response';
|
||||
break;
|
||||
case 'endpointActivityLog':
|
||||
queryParams.show = 'activity_log';
|
||||
break;
|
||||
}
|
||||
|
||||
const urlQueryParams = querystringStringify<EndpointDetailsUrlProps, typeof queryParams>(
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
createMockPackageService,
|
||||
createRouteHandlerContext,
|
||||
} from '../../mocks';
|
||||
import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions';
|
||||
import { registerHostIsolationRoutes } from './isolation';
|
||||
import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__';
|
||||
import { LicenseService } from '../../../../common/license';
|
||||
|
@ -55,282 +56,310 @@ const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode:
|
|||
const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } });
|
||||
|
||||
describe('Host Isolation', () => {
|
||||
let endpointAppContextService: EndpointAppContextService;
|
||||
let mockResponse: jest.Mocked<KibanaResponseFactory>;
|
||||
let licenseService: LicenseService;
|
||||
let licenseEmitter: Subject<ILicense>;
|
||||
|
||||
let callRoute: (
|
||||
routePrefix: string,
|
||||
opts: CallRouteInterface
|
||||
) => Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>>;
|
||||
const superUser = {
|
||||
username: 'superuser',
|
||||
roles: ['superuser'],
|
||||
};
|
||||
|
||||
const docGen = new EndpointDocGenerator();
|
||||
|
||||
beforeEach(() => {
|
||||
// instantiate... everything
|
||||
const mockScopedClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
const mockClusterClient = elasticsearchServiceMock.createClusterClient();
|
||||
mockClusterClient.asScoped.mockReturnValue(mockScopedClient);
|
||||
const routerMock = httpServiceMock.createRouter();
|
||||
mockResponse = httpServerMock.createResponseFactory();
|
||||
const startContract = createMockEndpointAppContextServiceStartContract();
|
||||
endpointAppContextService = new EndpointAppContextService();
|
||||
const mockSavedObjectClient = savedObjectsClientMock.create();
|
||||
const mockPackageService = createMockPackageService();
|
||||
mockPackageService.getInstalledEsAssetReferences.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'logs-endpoint.events.security',
|
||||
type: ElasticsearchAssetType.indexTemplate,
|
||||
},
|
||||
{
|
||||
id: `${metadataTransformPrefix}-0.16.0-dev.0`,
|
||||
type: ElasticsearchAssetType.transform,
|
||||
},
|
||||
])
|
||||
);
|
||||
licenseEmitter = new Subject();
|
||||
licenseService = new LicenseService();
|
||||
licenseService.start(licenseEmitter);
|
||||
endpointAppContextService.start({
|
||||
...startContract,
|
||||
licenseService,
|
||||
packageService: mockPackageService,
|
||||
describe('schema', () => {
|
||||
it('should require at least 1 Endpoint ID', () => {
|
||||
expect(() => {
|
||||
HostIsolationRequestSchema.body.validate({});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
// add the host isolation route handlers to routerMock
|
||||
registerHostIsolationRoutes(routerMock, {
|
||||
logFactory: loggingSystemMock.create(),
|
||||
service: endpointAppContextService,
|
||||
config: () => Promise.resolve(createMockConfig()),
|
||||
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
|
||||
it('should accept an Endpoint ID as the only required field', () => {
|
||||
expect(() => {
|
||||
HostIsolationRequestSchema.body.validate({
|
||||
endpoint_ids: ['ABC-XYZ-000'],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
// define a convenience function to execute an API call for a given route, body, and mocked response from ES
|
||||
// it returns the requestContext mock used in the call, to assert internal calls (e.g. the indexed document)
|
||||
callRoute = async (
|
||||
it('should accept a comment', () => {
|
||||
expect(() => {
|
||||
HostIsolationRequestSchema.body.validate({
|
||||
endpoint_ids: ['ABC-XYZ-000'],
|
||||
comment: 'a user comment',
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept alert IDs', () => {
|
||||
expect(() => {
|
||||
HostIsolationRequestSchema.body.validate({
|
||||
endpoint_ids: ['ABC-XYZ-000'],
|
||||
alert_ids: ['0000000-000-00'],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept case IDs', () => {
|
||||
expect(() => {
|
||||
HostIsolationRequestSchema.body.validate({
|
||||
endpoint_ids: ['ABC-XYZ-000'],
|
||||
case_ids: ['000000000-000-000'],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handler', () => {
|
||||
let endpointAppContextService: EndpointAppContextService;
|
||||
let mockResponse: jest.Mocked<KibanaResponseFactory>;
|
||||
let licenseService: LicenseService;
|
||||
let licenseEmitter: Subject<ILicense>;
|
||||
|
||||
let callRoute: (
|
||||
routePrefix: string,
|
||||
{ body, idxResponse, searchResponse, mockUser, license }: CallRouteInterface
|
||||
): Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>> => {
|
||||
const asUser = mockUser ? mockUser : superUser;
|
||||
(startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce(
|
||||
() => asUser
|
||||
);
|
||||
const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient);
|
||||
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
|
||||
ctx.core.elasticsearch.client.asCurrentUser.index = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => Promise.resolve(withIdxResp));
|
||||
ctx.core.elasticsearch.client.asCurrentUser.search = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve({ body: createV2SearchResponse(searchResponse) })
|
||||
);
|
||||
const withLicense = license ? license : Platinum;
|
||||
licenseEmitter.next(withLicense);
|
||||
const mockRequest = httpServerMock.createKibanaRequest({ body });
|
||||
const [, routeHandler]: [
|
||||
RouteConfig<any, any, any, any>,
|
||||
RequestHandler<any, any, any, any>
|
||||
] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(routePrefix))!;
|
||||
await routeHandler(ctx, mockRequest, mockResponse);
|
||||
return (ctx as unknown) as jest.Mocked<SecuritySolutionRequestHandlerContext>;
|
||||
opts: CallRouteInterface
|
||||
) => Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>>;
|
||||
const superUser = {
|
||||
username: 'superuser',
|
||||
roles: ['superuser'],
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
endpointAppContextService.stop();
|
||||
licenseService.stop();
|
||||
licenseEmitter.complete();
|
||||
});
|
||||
const docGen = new EndpointDocGenerator();
|
||||
|
||||
it('errors if no endpoint or agent is provided', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {});
|
||||
expect(mockResponse.badRequest).toBeCalled();
|
||||
});
|
||||
it('succeeds when an agent ID is provided', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, { body: { agent_ids: ['XYZ'] } });
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('reports elasticsearch errors creating an action', async () => {
|
||||
const ErrMessage = 'something went wrong?';
|
||||
beforeEach(() => {
|
||||
// instantiate... everything
|
||||
const mockScopedClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
const mockClusterClient = elasticsearchServiceMock.createClusterClient();
|
||||
mockClusterClient.asScoped.mockReturnValue(mockScopedClient);
|
||||
const routerMock = httpServiceMock.createRouter();
|
||||
mockResponse = httpServerMock.createResponseFactory();
|
||||
const startContract = createMockEndpointAppContextServiceStartContract();
|
||||
endpointAppContextService = new EndpointAppContextService();
|
||||
const mockSavedObjectClient = savedObjectsClientMock.create();
|
||||
const mockPackageService = createMockPackageService();
|
||||
mockPackageService.getInstalledEsAssetReferences.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'logs-endpoint.events.security',
|
||||
type: ElasticsearchAssetType.indexTemplate,
|
||||
},
|
||||
{
|
||||
id: `${metadataTransformPrefix}-0.16.0-dev.0`,
|
||||
type: ElasticsearchAssetType.transform,
|
||||
},
|
||||
])
|
||||
);
|
||||
licenseEmitter = new Subject();
|
||||
licenseService = new LicenseService();
|
||||
licenseService.start(licenseEmitter);
|
||||
endpointAppContextService.start({
|
||||
...startContract,
|
||||
licenseService,
|
||||
packageService: mockPackageService,
|
||||
});
|
||||
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
idxResponse: {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
result: ErrMessage,
|
||||
// add the host isolation route handlers to routerMock
|
||||
registerHostIsolationRoutes(routerMock, {
|
||||
logFactory: loggingSystemMock.create(),
|
||||
service: endpointAppContextService,
|
||||
config: () => Promise.resolve(createMockConfig()),
|
||||
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
|
||||
});
|
||||
|
||||
// define a convenience function to execute an API call for a given route, body, and mocked response from ES
|
||||
// it returns the requestContext mock used in the call, to assert internal calls (e.g. the indexed document)
|
||||
callRoute = async (
|
||||
routePrefix: string,
|
||||
{ body, idxResponse, searchResponse, mockUser, license }: CallRouteInterface
|
||||
): Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>> => {
|
||||
const asUser = mockUser ? mockUser : superUser;
|
||||
(startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce(
|
||||
() => asUser
|
||||
);
|
||||
const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient);
|
||||
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
|
||||
ctx.core.elasticsearch.client.asCurrentUser.index = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => Promise.resolve(withIdxResp));
|
||||
ctx.core.elasticsearch.client.asCurrentUser.search = jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ body: createV2SearchResponse(searchResponse) })
|
||||
);
|
||||
const withLicense = license ? license : Platinum;
|
||||
licenseEmitter.next(withLicense);
|
||||
const mockRequest = httpServerMock.createKibanaRequest({ body });
|
||||
const [, routeHandler]: [
|
||||
RouteConfig<any, any, any, any>,
|
||||
RequestHandler<any, any, any, any>
|
||||
] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(routePrefix))!;
|
||||
await routeHandler(ctx, mockRequest, mockResponse);
|
||||
return (ctx as unknown) as jest.Mocked<SecuritySolutionRequestHandlerContext>;
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
endpointAppContextService.stop();
|
||||
licenseService.stop();
|
||||
licenseEmitter.complete();
|
||||
});
|
||||
it('succeeds when an endpoint ID is provided', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, { body: { endpoint_ids: ['XYZ'] } });
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('reports elasticsearch errors creating an action', async () => {
|
||||
const ErrMessage = 'something went wrong?';
|
||||
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
idxResponse: {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
result: ErrMessage,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockResponse.ok).not.toBeCalled();
|
||||
const response = mockResponse.customError.mock.calls[0][0];
|
||||
expect(response.statusCode).toEqual(500);
|
||||
expect((response.body as Error).message).toEqual(ErrMessage);
|
||||
});
|
||||
it('accepts a comment field', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, { body: { agent_ids: ['XYZ'], comment: 'XYZ' } });
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('sends the action to the requested agent', async () => {
|
||||
const AgentID = '123-ABC';
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: [AgentID] },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.agents).toContain(AgentID);
|
||||
});
|
||||
it('records the user who performed the action to the action record', async () => {
|
||||
const testU = { username: 'testuser', roles: ['superuser'] };
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
mockUser: testU,
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.user_id).toEqual(testU.username);
|
||||
});
|
||||
it('records the comment in the action payload', async () => {
|
||||
const CommentText = "I am isolating this because it's Friday";
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'], comment: CommentText },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.data.comment).toEqual(CommentText);
|
||||
});
|
||||
it('creates an action and returns its ID', async () => {
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'], comment: 'XYZ' },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
const actionID = actionDoc.action_id;
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
expect((mockResponse.ok.mock.calls[0][0]?.body as HostIsolationResponse).action).toEqual(
|
||||
actionID
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds when just an endpoint ID is provided', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, { body: { endpoint_ids: ['XYZ'] } });
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('sends the action to the correct agent when endpoint ID is given', async () => {
|
||||
const doc = docGen.generateHostMetadata();
|
||||
const AgentID = doc.elastic.agent.id;
|
||||
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
searchResponse: doc,
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.agents).toContain(AgentID);
|
||||
});
|
||||
it('combines given agent IDs and endpoint IDs', async () => {
|
||||
const doc = docGen.generateHostMetadata();
|
||||
const explicitAgentID = 'XYZ';
|
||||
const lookupAgentID = doc.elastic.agent.id;
|
||||
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: [explicitAgentID], endpoint_ids: ['XYZ'] },
|
||||
searchResponse: doc,
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.agents).toContain(explicitAgentID);
|
||||
expect(actionDoc.agents).toContain(lookupAgentID);
|
||||
});
|
||||
|
||||
it('sends the isolate command payload from the isolate route', async () => {
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.data.command).toEqual('isolate');
|
||||
});
|
||||
it('sends the unisolate command payload from the unisolate route', async () => {
|
||||
const ctx = await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.data.command).toEqual('unisolate');
|
||||
});
|
||||
|
||||
describe('License Level', () => {
|
||||
it('allows platinum license levels to isolate hosts', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
license: Platinum,
|
||||
});
|
||||
expect(mockResponse.ok).not.toBeCalled();
|
||||
const response = mockResponse.customError.mock.calls[0][0];
|
||||
expect(response.statusCode).toEqual(500);
|
||||
expect((response.body as Error).message).toEqual(ErrMessage);
|
||||
});
|
||||
it('accepts a comment field', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, { body: { endpoint_ids: ['XYZ'], comment: 'XYZ' } });
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('prohibits license levels less than platinum from isolating hosts', async () => {
|
||||
licenseEmitter.next(Gold);
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
license: Gold,
|
||||
it('sends the action to the requested agent', async () => {
|
||||
const metadataResponse = docGen.generateHostMetadata();
|
||||
const AgentID = metadataResponse.elastic.agent.id;
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['ABC-XYZ-000'] },
|
||||
searchResponse: metadataResponse,
|
||||
});
|
||||
expect(mockResponse.forbidden).toBeCalled();
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.agents).toContain(AgentID);
|
||||
});
|
||||
it('allows any license level to unisolate', async () => {
|
||||
licenseEmitter.next(Gold);
|
||||
await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
license: Gold,
|
||||
it('records the user who performed the action to the action record', async () => {
|
||||
const testU = { username: 'testuser', roles: ['superuser'] };
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
mockUser: testU,
|
||||
});
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.user_id).toEqual(testU.username);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Level', () => {
|
||||
it('allows superuser to perform isolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['superuser'] };
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
it('records the comment in the action payload', async () => {
|
||||
const CommentText = "I am isolating this because it's Friday";
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'], comment: CommentText },
|
||||
});
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.data.comment).toEqual(CommentText);
|
||||
});
|
||||
it('allows superuser to perform unisolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['superuser'] };
|
||||
await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
it('creates an action and returns its ID', async () => {
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'], comment: 'XYZ' },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
const actionID = actionDoc.action_id;
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
expect((mockResponse.ok.mock.calls[0][0]?.body as HostIsolationResponse).action).toEqual(
|
||||
actionID
|
||||
);
|
||||
});
|
||||
|
||||
it('prohibits non-admin user from performing isolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['user'] };
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
});
|
||||
expect(mockResponse.forbidden).toBeCalled();
|
||||
it('succeeds when just an endpoint ID is provided', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, { body: { endpoint_ids: ['XYZ'] } });
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('prohibits non-admin user from performing unisolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['user'] };
|
||||
await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { agent_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
});
|
||||
expect(mockResponse.forbidden).toBeCalled();
|
||||
});
|
||||
});
|
||||
it('sends the action to the correct agent when endpoint ID is given', async () => {
|
||||
const doc = docGen.generateHostMetadata();
|
||||
const AgentID = doc.elastic.agent.id;
|
||||
|
||||
describe('Cases', () => {
|
||||
it.todo('logs a comment to the provided case');
|
||||
it.todo('logs a comment to any cases associated with the given alert');
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
searchResponse: doc,
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.agents).toContain(AgentID);
|
||||
});
|
||||
|
||||
it('sends the isolate command payload from the isolate route', async () => {
|
||||
const ctx = await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.data.command).toEqual('isolate');
|
||||
});
|
||||
it('sends the unisolate command payload from the unisolate route', async () => {
|
||||
const ctx = await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
});
|
||||
const actionDoc: EndpointAction = (ctx.core.elasticsearch.client.asCurrentUser
|
||||
.index as jest.Mock).mock.calls[0][0].body;
|
||||
expect(actionDoc.data.command).toEqual('unisolate');
|
||||
});
|
||||
|
||||
describe('License Level', () => {
|
||||
it('allows platinum license levels to isolate hosts', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
license: Platinum,
|
||||
});
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('prohibits license levels less than platinum from isolating hosts', async () => {
|
||||
licenseEmitter.next(Gold);
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
license: Gold,
|
||||
});
|
||||
expect(mockResponse.forbidden).toBeCalled();
|
||||
});
|
||||
it('allows any license level to unisolate', async () => {
|
||||
licenseEmitter.next(Gold);
|
||||
await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
license: Gold,
|
||||
});
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Level', () => {
|
||||
it('allows superuser to perform isolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['superuser'] };
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
});
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
it('allows superuser to perform unisolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['superuser'] };
|
||||
await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
});
|
||||
expect(mockResponse.ok).toBeCalled();
|
||||
});
|
||||
|
||||
it('prohibits non-admin user from performing isolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['user'] };
|
||||
await callRoute(ISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
});
|
||||
expect(mockResponse.forbidden).toBeCalled();
|
||||
});
|
||||
it('prohibits non-admin user from performing unisolation', async () => {
|
||||
const superU = { username: 'foo', roles: ['user'] };
|
||||
await callRoute(UNISOLATE_HOST_ROUTE, {
|
||||
body: { endpoint_ids: ['XYZ'] },
|
||||
mockUser: superU,
|
||||
});
|
||||
expect(mockResponse.forbidden).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cases', () => {
|
||||
it.todo('logs a comment to the provided case');
|
||||
it.todo('logs a comment to any cases associated with the given alert');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,12 +14,12 @@ import { CasesByAlertId } from '../../../../../cases/common/api/cases/case';
|
|||
import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions';
|
||||
import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common';
|
||||
import { EndpointAction } from '../../../../common/endpoint/types';
|
||||
import { EndpointAction, HostMetadata } from '../../../../common/endpoint/types';
|
||||
import {
|
||||
SecuritySolutionPluginRouter,
|
||||
SecuritySolutionRequestHandlerContext,
|
||||
} from '../../../types';
|
||||
import { getAgentIDsForEndpoints } from '../../services';
|
||||
import { getMetadataForEndpoints } from '../../services';
|
||||
import { EndpointAppContext } from '../../types';
|
||||
import { APP_ID } from '../../../../common/constants';
|
||||
import { userCanIsolate } from '../../../../common/endpoint/actions';
|
||||
|
@ -61,19 +61,7 @@ export const isolationRequestHandler = function (
|
|||
TypeOf<typeof HostIsolationRequestSchema.body>,
|
||||
SecuritySolutionRequestHandlerContext
|
||||
> {
|
||||
// eslint-disable-next-line complexity
|
||||
return async (context, req, res) => {
|
||||
if (
|
||||
(!req.body.agent_ids || req.body.agent_ids.length === 0) &&
|
||||
(!req.body.endpoint_ids || req.body.endpoint_ids.length === 0)
|
||||
) {
|
||||
return res.badRequest({
|
||||
body: {
|
||||
message: 'At least one agent ID or endpoint ID is required',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// only allow admin users
|
||||
const user = endpointContext.service.security?.authc.getCurrentUser(req);
|
||||
if (!userCanIsolate(user?.roles)) {
|
||||
|
@ -93,13 +81,9 @@ export const isolationRequestHandler = function (
|
|||
});
|
||||
}
|
||||
|
||||
// translate any endpoint_ids into agent_ids
|
||||
let agentIDs = req.body.agent_ids?.slice() || [];
|
||||
if (req.body.endpoint_ids && req.body.endpoint_ids.length > 0) {
|
||||
const newIDs = await getAgentIDsForEndpoints(req.body.endpoint_ids, context, endpointContext);
|
||||
agentIDs = agentIDs.concat(newIDs);
|
||||
}
|
||||
agentIDs = [...new Set(agentIDs)]; // dedupe
|
||||
// fetch the Agent IDs to send the commands to
|
||||
const endpointIDs = [...new Set(req.body.endpoint_ids)]; // dedupe
|
||||
const endpointData = await getMetadataForEndpoints(endpointIDs, context, endpointContext);
|
||||
|
||||
const casesClient = await endpointContext.service.getCasesClient(req);
|
||||
|
||||
|
@ -134,7 +118,7 @@ export const isolationRequestHandler = function (
|
|||
expiration: moment().add(2, 'weeks').toISOString(),
|
||||
type: 'INPUT_ACTION',
|
||||
input_type: 'endpoint',
|
||||
agents: agentIDs,
|
||||
agents: endpointData.map((endpt: HostMetadata) => endpt.elastic.agent.id),
|
||||
user_id: user!.username,
|
||||
data: {
|
||||
command: isolate ? 'isolate' : 'unisolate',
|
||||
|
@ -158,25 +142,24 @@ export const isolationRequestHandler = function (
|
|||
});
|
||||
}
|
||||
|
||||
const commentLines: string[] = [];
|
||||
|
||||
commentLines.push(`${isolate ? 'I' : 'Uni'}solate action was sent to the following Agents:`);
|
||||
// lines of markdown links, inside a code block
|
||||
|
||||
commentLines.push(`${agentIDs.map((a) => `- [${a}](/app/fleet#/agents/${a})`).join('\n')}`);
|
||||
if (req.body.comment) {
|
||||
commentLines.push(`\n\nWith Comment:\n> ${req.body.comment}`);
|
||||
}
|
||||
|
||||
// Update all cases with a comment
|
||||
if (caseIDs.length > 0) {
|
||||
const targets = endpointData.map((endpt: HostMetadata) => ({
|
||||
hostname: endpt.host.hostname,
|
||||
endpointId: endpt.agent.id,
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
caseIDs.map((caseId) =>
|
||||
casesClient.attachments.add({
|
||||
caseId,
|
||||
comment: {
|
||||
comment: commentLines.join('\n'),
|
||||
type: CommentType.user,
|
||||
type: CommentType.actions,
|
||||
comment: req.body.comment || '',
|
||||
actions: {
|
||||
targets,
|
||||
type: isolate ? 'isolate' : 'unisolate',
|
||||
},
|
||||
owner: APP_ID,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -6,4 +6,4 @@
|
|||
*/
|
||||
|
||||
export * from './artifacts';
|
||||
export { getAgentIDsForEndpoints } from './lookup_agent';
|
||||
export { getMetadataForEndpoints } from './metadata';
|
||||
|
|
|
@ -12,11 +12,11 @@ import { SecuritySolutionRequestHandlerContext } from '../../types';
|
|||
import { getESQueryHostMetadataByIDs } from '../routes/metadata/query_builders';
|
||||
import { EndpointAppContext } from '../types';
|
||||
|
||||
export async function getAgentIDsForEndpoints(
|
||||
export async function getMetadataForEndpoints(
|
||||
endpointIDs: string[],
|
||||
requestHandlerContext: SecuritySolutionRequestHandlerContext,
|
||||
endpointAppContext: EndpointAppContext
|
||||
): Promise<string[]> {
|
||||
): Promise<HostMetadata[]> {
|
||||
const queryStrategy = await endpointAppContext.service
|
||||
?.getMetadataService()
|
||||
?.queryStrategy(requestHandlerContext.core.savedObjects.client);
|
||||
|
@ -25,6 +25,5 @@ export async function getAgentIDsForEndpoints(
|
|||
const esClient = requestHandlerContext.core.elasticsearch.client.asCurrentUser;
|
||||
const { body } = await esClient.search<HostMetadata>(query as SearchRequest);
|
||||
const hosts = queryStrategy!.queryResponseToHostListResult(body as SearchResponse<HostMetadata>);
|
||||
|
||||
return hosts.resultList.map((x: HostMetadata): string => x.elastic.agent.id);
|
||||
return hosts.resultList;
|
||||
}
|
Loading…
Reference in a new issue