[Security Solution][Endpoint][Host Isolation][Cases] Update Host Isolation comment in Cases UI (#102937)

This commit is contained in:
Candace Park 2021-06-29 19:42:38 -04:00 committed by GitHub
parent f039f8311f
commit e749fa62fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 604 additions and 299 deletions

View file

@ -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>;

View file

@ -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(),

View file

@ -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}

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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}}`,
});

View file

@ -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);

View file

@ -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);

View file

@ -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.
*/

View file

@ -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',
},

View file

@ -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 */

View file

@ -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) => {

View file

@ -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'],

View file

@ -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>(

View file

@ -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');
});
});
});

View file

@ -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,
},
})

View file

@ -6,4 +6,4 @@
*/
export * from './artifacts';
export { getAgentIDsForEndpoints } from './lookup_agent';
export { getMetadataForEndpoints } from './metadata';

View file

@ -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;
}