[Security Solution] [Exceptions] Adds options to create a shared exception list and to create a single item from the manage exceptions view (#144575)

Adds options to create a shared exception list and creating a single
item to be attached to multiple rules default lists or to add it to
shared lists.

Co-authored-by: Gloria Hornero <gloria.hornero@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Devin W. Hurley 2022-11-09 09:54:45 -05:00 committed by GitHub
parent 9298023c1b
commit b1179e72ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 481 additions and 7 deletions

View file

@ -8,6 +8,7 @@
import React from 'react';
import { EuiComboBox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldProps } from './types';
import { useField } from './use_field';
@ -62,7 +63,10 @@ export const FieldComponent: React.FC<FieldProps> = ({
data-test-subj="fieldAutocompleteComboBox"
style={fieldWidth}
onCreateOption={handleCreateCustomOption}
customOptionText="Add {searchValue} as your occupation"
customOptionText={i18n.translate('autocomplete.customOptionText', {
defaultMessage: 'Add {searchValuePlaceholder} as a custom field',
values: { searchValuePlaceholder: '{searchValue}' },
})}
fullWidth
/>
);

View file

@ -21,8 +21,8 @@ export const exceptionListTypeOrUndefined = t.union([exceptionListType, t.undefi
export type ExceptionListType = t.TypeOf<typeof exceptionListType>;
export type ExceptionListTypeOrUndefined = t.TypeOf<typeof exceptionListTypeOrUndefined>;
export enum ExceptionListTypeEnum {
DETECTION = 'detection',
RULE_DEFAULT = 'rule_default',
DETECTION = 'detection', // shared exception list type
RULE_DEFAULT = 'rule_default', // rule default, cannot be shared
ENDPOINT = 'endpoint',
ENDPOINT_TRUSTED_APPS = 'endpoint',
ENDPOINT_EVENTS = 'endpoint_events',

View file

@ -256,6 +256,12 @@ export const LEGACY_NOTIFICATIONS_ID = `siem.notifications` as const;
*/
export const UPDATE_OR_CREATE_LEGACY_ACTIONS = '/internal/api/detection/legacy/notifications';
/**
* Exceptions management routes
*/
export const SHARED_EXCEPTION_LIST_URL = `/api${EXCEPTIONS_PATH}/shared` as const;
/**
* Detection engine routes
*/

View file

@ -0,0 +1,188 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ChangeEvent } from 'react';
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import {
EuiFlyout,
EuiTitle,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiText,
EuiFieldText,
EuiSpacer,
EuiTextArea,
EuiFlyoutFooter,
EuiFlexGroup,
EuiButtonEmpty,
EuiButton,
EuiFlexItem,
} from '@elastic/eui';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { ErrorToastOptions, Toast, ToastInput } from '@kbn/core-notifications-browser';
import { i18n as translate } from '@kbn/i18n';
import type { ListDetails } from '@kbn/securitysolution-exception-list-components';
import { useCreateSharedExceptionListWithOptionalSignal } from './use_create_shared_list';
import {
CREATE_SHARED_LIST_TITLE,
CREATE_SHARED_LIST_NAME_FIELD,
CREATE_SHARED_LIST_DESCRIPTION,
CREATE_BUTTON,
CLOSE_FLYOUT,
CREATE_SHARED_LIST_DESCRIPTION_PLACEHOLDER,
CREATE_SHARED_LIST_NAME_FIELD_PLACEHOLDER,
SUCCESS_TITLE,
getSuccessText,
} from './translations';
export const CreateSharedListFlyout = memo(
({
handleRefresh,
http,
handleCloseFlyout,
addSuccess,
addError,
}: {
handleRefresh: () => void;
http: HttpSetup;
addSuccess: (toastOrTitle: ToastInput, options?: unknown) => Toast;
addError: (error: unknown, options: ErrorToastOptions) => Toast;
handleCloseFlyout: () => void;
}) => {
const { start: createSharedExceptionList, ...createSharedExceptionListState } =
useCreateSharedExceptionListWithOptionalSignal();
const ctrl = useRef(new AbortController());
enum DetailProperty {
name = 'name',
description = 'description',
}
const [newListDetails, setNewListDetails] = useState<ListDetails>({
name: '',
description: '',
});
const onChange = (
{ target }: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
detailProperty: DetailProperty.name | DetailProperty.description
) => {
const { value } = target;
setNewListDetails({ ...newListDetails, [detailProperty]: value });
};
const handleCreateSharedExceptionList = useCallback(() => {
if (!createSharedExceptionListState.loading && newListDetails.name !== '') {
ctrl.current = new AbortController();
createSharedExceptionList({
http,
signal: ctrl.current.signal,
name: newListDetails.name,
description: newListDetails.description ?? '',
});
}
}, [createSharedExceptionList, createSharedExceptionListState.loading, newListDetails, http]);
const handleCreateSuccess = useCallback(
(response) => {
addSuccess({
text: getSuccessText(newListDetails.name),
title: SUCCESS_TITLE,
});
handleRefresh();
handleCloseFlyout();
},
[addSuccess, handleCloseFlyout, handleRefresh, newListDetails]
);
const handleCreateError = useCallback(
(error) => {
if (!error.message.includes('AbortError') && !error?.body?.message.includes('AbortError')) {
addError(error, {
title: translate.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListErrorTitle',
{
defaultMessage: 'creation error',
}
),
});
}
},
[addError]
);
useEffect(() => {
if (!createSharedExceptionListState.loading) {
if (createSharedExceptionListState?.result) {
handleCreateSuccess(createSharedExceptionListState.result);
} else if (createSharedExceptionListState?.error) {
handleCreateError(createSharedExceptionListState?.error);
}
}
}, [
createSharedExceptionListState?.error,
createSharedExceptionListState.loading,
createSharedExceptionListState.result,
handleCreateError,
handleCreateSuccess,
]);
return (
<EuiFlyout
ownFocus
size="s"
onClose={handleCloseFlyout}
data-test-subj="createSharedExceptionListFlyout"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 data-test-subj="createSharedExceptionListTitle">{CREATE_SHARED_LIST_TITLE}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>{CREATE_SHARED_LIST_NAME_FIELD}</EuiText>
<EuiFieldText
placeholder={CREATE_SHARED_LIST_NAME_FIELD_PLACEHOLDER}
value={newListDetails.name}
onChange={(e) => onChange(e, DetailProperty.name)}
aria-label="Use aria labels when no actual label is in use"
/>
<EuiSpacer />
<EuiText>{CREATE_SHARED_LIST_DESCRIPTION}</EuiText>
<EuiTextArea
placeholder={CREATE_SHARED_LIST_DESCRIPTION_PLACEHOLDER}
value={newListDetails.description}
onChange={(e) => onChange(e, DetailProperty.description)}
aria-label="Stop the hackers"
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={handleCloseFlyout} flush="left">
{CLOSE_FLYOUT}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="exception-lists-form-create-shared"
onClick={handleCreateSharedExceptionList}
disabled={newListDetails.name === ''}
>
{CREATE_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}
);
CreateSharedListFlyout.displayName = 'CreateSharedListFlyout';

View file

@ -46,6 +46,9 @@ import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../common/endpoint/service
import { ExceptionsListCard } from './exceptions_list_card';
import { ImportExceptionListFlyout } from './import_exceptions_list_flyout';
import { CreateSharedListFlyout } from './create_shared_exception_list';
import { AddExceptionFlyout } from '../../detection_engine/rule_exceptions/components/add_exception_flyout';
export type Func = () => Promise<void>;
@ -355,6 +358,16 @@ export const ExceptionListsTable = React.memo(() => {
const goToPage = (pageNumber: number) => setActivePage(pageNumber);
const [isCreatePopoverOpen, setIsCreatePopoverOpen] = useState(false);
const [displayAddExceptionItemFlyout, setDisplayAddExceptionItemFlyout] = useState(false);
const [displayCreateSharedListFlyout, setDisplayCreateSharedListFlyout] = useState(false);
const onCreateButtonClick = () => setIsCreatePopoverOpen((isOpen) => !isOpen);
const onCloseCreatePopover = () => {
setDisplayAddExceptionItemFlyout(false);
setIsCreatePopoverOpen(false);
};
return (
<>
<MissingPrivilegesCallOut />
@ -370,11 +383,67 @@ export const ExceptionListsTable = React.memo(() => {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton iconType={'importAction'} onClick={() => setDisplayImportListFlyout(true)}>
{i18n.IMPORT_EXCEPTION_LIST}
{i18n.IMPORT_EXCEPTION_LIST_BUTTON}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
data-test-subj="manageExceptionListCreateButton"
button={
<EuiButton iconType={'arrowDown'} onClick={onCreateButtonClick}>
{i18n.CREATE_BUTTON}
</EuiButton>
}
isOpen={isCreatePopoverOpen}
closePopover={onCloseCreatePopover}
>
<EuiContextMenuPanel
items={[
<EuiContextMenuItem
key={'createList'}
onClick={() => {
onCloseCreatePopover();
setDisplayCreateSharedListFlyout(true);
}}
>
{i18n.CREATE_SHARED_LIST_BUTTON}
</EuiContextMenuItem>,
<EuiContextMenuItem
key={'createItem'}
onClick={() => {
onCloseCreatePopover();
setDisplayAddExceptionItemFlyout(true);
}}
>
{i18n.CREATE_BUTTON_ITEM_BUTTON}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
{displayCreateSharedListFlyout && (
<CreateSharedListFlyout
handleRefresh={handleRefresh}
http={http}
addSuccess={addSuccess}
addError={addError}
handleCloseFlyout={() => setDisplayCreateSharedListFlyout(false)}
/>
)}
{displayAddExceptionItemFlyout && (
<AddExceptionFlyout
rules={null}
isEndpointItem={false}
isBulkAction={false}
showAlertCloseOptions
onCancel={(didRuleChange: boolean) => setDisplayAddExceptionItemFlyout(false)}
onConfirm={(didRuleChange: boolean) => setDisplayAddExceptionItemFlyout(false)}
/>
)}
{displayImportListFlyout && (
<ImportExceptionListFlyout
handleRefresh={handleRefresh}

View file

@ -36,7 +36,7 @@ export const EXPORT_EXCEPTION_LIST = i18n.translate(
);
export const IMPORT_EXCEPTION_LIST_HEADER = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListFlyoutHeader',
'xpack.securitySolution.exceptionsTable.importExceptionListHeader',
{
defaultMessage: 'Import shared exception list',
}
@ -104,3 +104,58 @@ export const IMPORT_PROMPT = i18n.translate(
defaultMessage: 'Select or drag and drop multiple files',
}
);
export const CREATE_SHARED_LIST_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListTitle',
{
defaultMessage: 'Create shared exception list',
}
);
export const CREATE_SHARED_LIST_NAME_FIELD = i18n.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutNameField',
{
defaultMessage: 'Shared exception list name',
}
);
export const CREATE_SHARED_LIST_NAME_FIELD_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutNameFieldPlaceholder',
{
defaultMessage: 'New exception list',
}
);
export const CREATE_SHARED_LIST_DESCRIPTION = i18n.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutDescription',
{
defaultMessage: 'Description (optional)',
}
);
export const CREATE_SHARED_LIST_DESCRIPTION_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutDescriptionPlaceholder',
{
defaultMessage: 'New exception list',
}
);
export const CREATE_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutCreateButton',
{
defaultMessage: 'Create shared exception list',
}
);
export const getSuccessText = (listName: string) =>
i18n.translate('xpack.securitySolution.exceptions.createSharedExceptionListSuccessDescription', {
defaultMessage: 'list with name ${listName} was created!',
values: { listName },
});
export const SUCCESS_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.createSharedExceptionListSuccessTitle',
{
defaultMessage: 'created list',
}
);

View file

@ -195,9 +195,30 @@ export const UPLOAD_ERROR = i18n.translate(
}
);
export const IMPORT_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.lists.importExceptionListButton',
export const IMPORT_EXCEPTION_LIST_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.manageExceptions.importExceptionList',
{
defaultMessage: 'Import exception list',
}
);
export const CREATE_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.manageExceptions.create',
{
defaultMessage: 'Create',
}
);
export const CREATE_SHARED_LIST_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.manageExceptions.createSharedListButton',
{
defaultMessage: 'create shared list',
}
);
export const CREATE_BUTTON_ITEM_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.manageExceptions.createItemButton',
{
defaultMessage: 'create exception item',
}
);

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types';
import type { HttpStart } from '@kbn/core/public';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
import { SHARED_EXCEPTION_LIST_URL } from '../../../common/constants';
export const createSharedExceptionList = async ({
name,
description,
http,
signal,
}: {
http: HttpStart;
signal: AbortSignal;
name: string;
description: string;
}): Promise<ExceptionListSchema> => {
const res: ExceptionListSchema = await http.post<ExceptionListSchema>(SHARED_EXCEPTION_LIST_URL, {
body: JSON.stringify({ name, description }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal,
});
return res;
};
const createSharedExceptionListWithOptionalSignal = withOptionalSignal(createSharedExceptionList);
export const useCreateSharedExceptionListWithOptionalSignal = () =>
useAsync(createSharedExceptionListWithOptionalSignal);

View file

@ -0,0 +1,77 @@
/*
* 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 * as t from 'io-ts';
import uuid from 'uuid';
import { SHARED_EXCEPTION_LIST_URL } from '../../../../../common/constants';
import type { SecuritySolutionPluginRouter } from '../../../../types';
import { buildSiemResponse } from '../../../detection_engine/routes/utils';
import { buildRouteValidation } from '../../../../utils/build_validation/route_validation';
/**
* URL path parameters of the API route.
*/
export const CreateSharedExceptionListRequestParams = t.exact(
t.type({
name: t.string,
description: t.string,
})
);
export type CreateSharedExceptionListRequestParams = t.TypeOf<
typeof CreateSharedExceptionListRequestParams
>;
export type CreateSharedExceptionListRequestParamsDecoded = CreateSharedExceptionListRequestParams;
export const createSharedExceptionListRoute = (router: SecuritySolutionPluginRouter) => {
router.post(
{
path: SHARED_EXCEPTION_LIST_URL,
validate: {
body: buildRouteValidation<
typeof CreateSharedExceptionListRequestParams,
CreateSharedExceptionListRequestParams
>(CreateSharedExceptionListRequestParams),
},
options: {
tags: ['access:securitySolution'],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const { description, name } = request.body;
try {
const ctx = await context.resolve([
'core',
'securitySolution',
'alerting',
'licensing',
'lists',
]);
const listsClient = ctx.securitySolution.getExceptionListClient();
const createdSharedList = await listsClient?.createExceptionList({
description,
immutable: false,
listId: uuid.v4(),
meta: undefined,
name,
namespaceType: 'agnostic',
tags: [],
type: 'detection',
version: 1,
});
return response.ok({ body: createdSharedList });
} catch (exc) {
return siemResponse.error({
body: exc.message,
statusCode: 404,
});
}
}
);
};

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SecuritySolutionPluginRouter } from '../../../types';
import { createSharedExceptionListRoute } from './manage_exceptions/route';
export const registerManageExceptionsRoutes = (router: SecuritySolutionPluginRouter) => {
createSharedExceptionListRoute(router);
};

View file

@ -71,6 +71,7 @@ import {
installRiskScoresRoute,
readPrebuiltDevToolContentRoute,
} from '../lib/risk_score/routes';
import { registerManageExceptionsRoutes } from '../lib/exceptions/api/register_routes';
export const initRoutes = (
router: SecuritySolutionPluginRouter,
@ -92,6 +93,7 @@ export const initRoutes = (
registerLegacyRuleActionsRoutes(router, logger);
registerPrebuiltRulesRoutes(router, config, security);
registerRuleExceptionsRoutes(router);
registerManageExceptionsRoutes(router);
registerRuleManagementRoutes(router, config, ml, logger);
registerRuleMonitoringRoutes(router);
registerRulePreviewRoutes(