[Security Solution] [Exceptions] Add ListExceptionItem Component and its components with implementing the logic + restructuring exceptions under security (#144622)

## Summary

**Shared List Collapsed**
<img width="1226" alt="image"
src="https://user-images.githubusercontent.com/12671903/200085472-ba638911-af4e-4e6d-85a3-7692b174fad5.png">

**One Shared list expanded**
<img width="1442" alt="image"
src="https://user-images.githubusercontent.com/12671903/200085548-2b6ef100-8587-47c5-b08d-9727bd2c25c0.png">

**Shared List with no Exceptions**

<img width="1180" alt="image"
src="https://user-images.githubusercontent.com/12671903/200531307-4b3d0d02-a7c7-4232-98cc-7d0f1b4e48c5.png">

**Add Exceptions from Shared List Card**
<img width="1461" alt="image"
src="https://user-images.githubusercontent.com/12671903/200531393-833173ea-c5a8-4ab3-b947-257154f6aa90.png">

**Exit Exception from Shared List Card**
<img width="1463" alt="image"
src="https://user-images.githubusercontent.com/12671903/200531750-e888a0fa-d95c-4994-8ead-f119611fc561.png">



**Delete Endpoint is disabled**
<img width="1186" alt="image"
src="https://user-images.githubusercontent.com/12671903/200531542-773348c2-c2e2-4062-94e1-12340756ebc3.png">




1. **New components**
a. `list_details_link_anchor` => This component should be removed and
moved to @kbn/securitysolution-exception-list-components once all the
building components get moved
b. `exceptions_utility` => This component should be removed and moved to
@kbn/securitysolution-exception-list-components once all the building
components get moved
c. `list_exception_items ` a wrapper over the ExceptionItem from the
`@kbn/securitysolution-exception-list-components` added to pass the
missing above components, should be removed soon once everything gets
moved to the kbn package

2.  **New Hooks** 
a. `use_list_exception_items` => holds all the Exceptions' items' logic
  b. `use_exceptions_list.card` => hold all the exception card logic

4. Apply Designs to the Shared Lists

5. **Restructure folders under the
`x-pack=>security_solution=>exceptions`**
    a.  components
    b. hooks
    c. pages
    d. translations
    e. utils

6. Added excluded files in `jest.config`

7. Renamed the `shared_list` components in `routes`


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Wafaa Nasr 2022-11-09 20:59:50 +01:00 committed by GitHub
parent f7758e0ada
commit 47f38bc3df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1734 additions and 442 deletions

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fireEvent, render } from '@testing-library/react';
import { createEvent, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { HeaderMenu } from '.';
import { actions, actionsWithDisabledDelete } from '../mocks/header.mock';
@ -90,7 +90,6 @@ describe('HeaderMenu', () => {
expect(wrapper.queryByTestId('ActionItemedit')).not.toBeInTheDocument();
expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument();
});
it('should call onEdit if action has onClick', () => {
const onEdit = jest.fn();
const customAction = [...actions];
@ -113,4 +112,16 @@ describe('HeaderMenu', () => {
fireEvent.click(wrapper.getByTestId('EmptyButton'));
expect(wrapper.queryByTestId('MenuPanel')).toBeInTheDocument();
});
it('should stop propagation when clicking on the menu', () => {
const onEdit = jest.fn();
const customAction = [...actions];
customAction[0].onClick = onEdit;
const wrapper = render(
<HeaderMenu dataTestSubj="headerMenu" disableActions={false} actions={actions} />
);
const headerMenu = wrapper.getByTestId('headerMenuItems');
const click = createEvent.click(headerMenu);
const result = fireEvent(headerMenu, click);
expect(result).toBe(true);
});
});

View file

@ -20,12 +20,12 @@ import {
} from '@elastic/eui';
import { ButtonContentIconSide } from '@elastic/eui/src/components/button/_button_content_deprecated';
interface Action {
export interface Action {
key: string;
icon: string;
label: string | boolean;
disabled?: boolean;
onClick: () => void;
onClick: (e: React.MouseEvent<Element, MouseEvent>) => void;
}
interface HeaderMenuComponentProps {
disableActions: boolean;
@ -66,9 +66,9 @@ const HeaderMenuComponent: FC<HeaderMenuComponentProps> = ({
icon={action.icon}
disabled={action.disabled}
layoutAlign="center"
onClick={() => {
onClick={(e) => {
onClosePopover();
if (typeof action.onClick === 'function') action.onClick();
if (typeof action.onClick === 'function') action.onClick(e);
}}
>
{action.label}
@ -103,6 +103,7 @@ const HeaderMenuComponent: FC<HeaderMenuComponentProps> = ({
</EuiButtonIcon>
)
}
onClick={(e) => e.stopPropagation()}
panelPaddingSize={panelPaddingSize}
isOpen={isPopoverOpen}
closePopover={onClosePopover}

View file

@ -20,10 +20,9 @@ import {
searchForExceptionList,
waitForExceptionsTableToBeLoaded,
clearSearchSelection,
expandExceptionActions,
} from '../../../tasks/exceptions_table';
import {
EXCEPTIONS_TABLE_DELETE_BTN,
EXCEPTIONS_OVERFLOW_ACTIONS_BTN,
EXCEPTIONS_TABLE_LIST_NAME,
EXCEPTIONS_TABLE_SHOWING_LISTS,
} from '../../../screens/exceptions';
@ -182,8 +181,7 @@ describe('Exceptions Table - read only', () => {
cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`);
});
it('Delete icon is not shown', () => {
expandExceptionActions();
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('be.disabled');
it('Card menu actions should be disabled', () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().should('be.disabled');
});
});

View file

@ -35,7 +35,7 @@ export const ENTRY_DELETE_BTN = '[data-test-subj="builderItemEntryDeleteButton"]
export const CANCEL_BTN = '[data-test-subj="cancelExceptionAddButton"]';
export const EXCEPTIONS_OVERFLOW_ACTIONS_BTN =
'[data-test-subj="exceptionsListCardOverflowActions"]';
'[data-test-subj="sharedListOverflowCardButtonIcon"]';
export const EXCEPTIONS_TABLE = '[data-test-subj="pageContainer"]';
@ -43,9 +43,11 @@ export const EXCEPTIONS_TABLE_SEARCH = '[data-test-subj="exceptionsHeaderSearchI
export const EXCEPTIONS_TABLE_SHOWING_LISTS = '[data-test-subj="showingExceptionLists"]';
export const EXCEPTIONS_TABLE_DELETE_BTN = '[data-test-subj="exceptionsTableDeleteButton"]';
export const EXCEPTIONS_TABLE_DELETE_BTN =
'[data-test-subj="sharedListOverflowCardActionItemDelete"]';
export const EXCEPTIONS_TABLE_EXPORT_BTN = '[data-test-subj="exceptionsTableExportButton"]';
export const EXCEPTIONS_TABLE_EXPORT_BTN =
'[data-test-subj="sharedListOverflowCardActionItemExport"]';
export const EXCEPTIONS_TABLE_SEARCH_CLEAR =
'[data-test-subj="allExceptionListsPanel"] button.euiFormControlLayoutClearButton';

View file

@ -50,7 +50,7 @@ import type { Rule } from '../../../rule_management/logic/types';
import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions';
import { ExceptionsAddToRulesOrLists } from '../flyout_components/add_exception_to_rule_or_list';
import { useAddNewExceptionItems } from './use_add_new_exceptions';
import { entrichNewExceptionItems } from '../flyout_components/utils';
import { enrichNewExceptionItems } from '../flyout_components/utils';
import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts';
import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants';
@ -74,6 +74,7 @@ export interface AddExceptionFlyoutProps {
*/
isAlertDataLoading?: boolean;
alertStatus?: Status;
sharedListToAddTo?: ExceptionListSchema[];
onCancel: (didRuleChange: boolean) => void;
onConfirm: (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert: boolean) => void;
}
@ -106,6 +107,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
showAlertCloseOptions,
isAlertDataLoading,
alertStatus,
sharedListToAddTo,
onCancel,
onConfirm,
}: AddExceptionFlyoutProps) {
@ -125,6 +127,12 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
}
}, [rules]);
const getListType = useMemo(() => {
if (isEndpointItem) return ExceptionListTypeEnum.ENDPOINT;
if (sharedListToAddTo) return ExceptionListTypeEnum.DETECTION;
return ExceptionListTypeEnum.RULE_DEFAULT;
}, [isEndpointItem, sharedListToAddTo]);
const [
{
exceptionItemMeta: { name: exceptionItemName },
@ -151,10 +159,9 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
: rules != null && rules.length === 1
? 'add_to_rule'
: 'select_rules_to_add_to',
listType: isEndpointItem ? ExceptionListTypeEnum.ENDPOINT : ExceptionListTypeEnum.RULE_DEFAULT,
listType: getListType,
selectedRulesToAddTo: rules != null ? rules : [],
});
const hasAlertData = useMemo((): boolean => {
return alertData != null;
}, [alertData]);
@ -320,14 +327,17 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
try {
const ruleDefaultOptions = ['add_to_rule', 'add_to_rules', 'select_rules_to_add_to'];
const addToRules = ruleDefaultOptions.includes(addExceptionToRadioSelection);
const addToSharedLists = addExceptionToRadioSelection === 'add_to_lists';
const addToSharedLists =
!!sharedListToAddTo?.length ||
(addExceptionToRadioSelection === 'add_to_lists' && !isEmpty(exceptionListsToAddTo));
const sharedLists = sharedListToAddTo?.length ? sharedListToAddTo : exceptionListsToAddTo;
const items = entrichNewExceptionItems({
const items = enrichNewExceptionItems({
itemName: exceptionItemName,
commentToAdd: newComment,
addToRules,
addToSharedLists,
sharedLists: exceptionListsToAddTo,
sharedLists,
listType,
selectedOs: osTypesSelection,
items: exceptionItems,
@ -338,8 +348,8 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
selectedRulesToAddTo,
listType,
addToRules: addToRules && !isEmpty(selectedRulesToAddTo),
addToSharedLists: addToSharedLists && !isEmpty(exceptionListsToAddTo),
sharedLists: exceptionListsToAddTo,
addToSharedLists,
sharedLists,
});
const alertIdToClose = closeSingleAlert && alertData ? alertData._id : undefined;
@ -358,6 +368,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
setErrorSubmitting(e);
}
}, [
sharedListToAddTo,
submitNewExceptionItems,
addExceptionToRadioSelection,
exceptionItemName,
@ -463,7 +474,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
onFilterIndexPatterns={filterIndexPatterns}
/>
{listType !== ExceptionListTypeEnum.ENDPOINT && (
{listType !== ExceptionListTypeEnum.ENDPOINT && !sharedListToAddTo?.length && (
<>
<EuiHorizontalRule />
<ExceptionsAddToRulesOrLists

View file

@ -50,7 +50,7 @@ import { filterIndexPatterns } from '../../utils/helpers';
import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data';
import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts';
import { useFindExceptionListReferences } from '../../logic/use_find_references';
import { entrichExceptionItemsForUpdate } from '../flyout_components/utils';
import { enrichExceptionItemsForUpdate } from '../flyout_components/utils';
import { ExceptionItemComments } from '../item_comments';
import { createExceptionItemsReducer } from './reducer';
import { useEditExceptionItems } from './use_edit_exception';
@ -62,6 +62,7 @@ interface EditExceptionFlyoutProps {
itemToEdit: ExceptionListItemSchema;
showAlertCloseOptions: boolean;
rule?: Rule;
openedFromListDetailPage?: boolean;
onCancel: (arg: boolean) => void;
onConfirm: (arg: boolean) => void;
}
@ -97,6 +98,7 @@ const EditExceptionFlyoutComponent: React.FC<EditExceptionFlyoutProps> = ({
itemToEdit,
rule,
showAlertCloseOptions,
openedFromListDetailPage,
onCancel,
onConfirm,
}): JSX.Element => {
@ -244,7 +246,7 @@ const EditExceptionFlyoutComponent: React.FC<EditExceptionFlyoutProps> = ({
if (submitEditExceptionItems == null) return;
try {
const items = entrichExceptionItemsForUpdate({
const items = enrichExceptionItemsForUpdate({
itemName: exceptionItemName,
commentToAdd: newComment,
listType,
@ -339,7 +341,7 @@ const EditExceptionFlyoutComponent: React.FC<EditExceptionFlyoutProps> = ({
onSetErrorExists={setConditionsValidationError}
onFilterIndexPatterns={filterIndexPatterns}
/>
{listType === ExceptionListTypeEnum.DETECTION && (
{!openedFromListDetailPage && listType === ExceptionListTypeEnum.DETECTION && (
<>
<EuiHorizontalRule />
<ExceptionsLinkedToLists
@ -349,12 +351,14 @@ const EditExceptionFlyoutComponent: React.FC<EditExceptionFlyoutProps> = ({
/>
</>
)}
{listType === ExceptionListTypeEnum.RULE_DEFAULT && rule != null && (
<>
<EuiHorizontalRule />
<ExceptionsLinkedToRule rule={rule} />
</>
)}
{!openedFromListDetailPage &&
listType === ExceptionListTypeEnum.RULE_DEFAULT &&
rule != null && (
<>
<EuiHorizontalRule />
<ExceptionsLinkedToRule rule={rule} />
</>
)}
<EuiHorizontalRule />
<ExceptionItemComments
accordionTitle={

View file

@ -17,7 +17,7 @@ import {
enrichEndpointItems,
enrichItemsForDefaultRuleList,
enrichItemsForSharedLists,
entrichNewExceptionItems,
enrichNewExceptionItems,
} from './utils';
const getExceptionItems = (): ExceptionsBuilderReturnExceptionItem[] => [
@ -30,7 +30,7 @@ describe('add_exception_flyout#utils', () => {
const items = getExceptionItems();
expect(
entrichNewExceptionItems({
enrichNewExceptionItems({
itemName: 'My item',
commentToAdd: 'New comment',
addToRules: true,
@ -66,7 +66,7 @@ describe('add_exception_flyout#utils', () => {
];
expect(
entrichNewExceptionItems({
enrichNewExceptionItems({
itemName: 'My item',
commentToAdd: 'New comment',
addToRules: false,
@ -105,7 +105,7 @@ describe('add_exception_flyout#utils', () => {
];
expect(
entrichNewExceptionItems({
enrichNewExceptionItems({
itemName: 'My item',
commentToAdd: 'New comment',
addToRules: false,

View file

@ -111,7 +111,7 @@ export const enrichItemsForSharedLists =
* @param listType exception list type
* @param items exception items to be modified
*/
export const entrichNewExceptionItems = ({
export const enrichNewExceptionItems = ({
itemName,
commentToAdd,
addToRules,
@ -152,7 +152,7 @@ export const entrichNewExceptionItems = ({
* @param listType exception list type
* @param items exception items to be modified
*/
export const entrichExceptionItemsForUpdate = ({
export const enrichExceptionItemsForUpdate = ({
itemName,
commentToAdd,
selectedOs,

View file

@ -6,7 +6,7 @@
*/
import { Query } from '@elastic/eui';
import { EXCEPTIONS_SEARCH_SCHEMA } from '../../../../exceptions/manage_exceptions/exceptions_search_bar';
import { EXCEPTIONS_SEARCH_SCHEMA } from '../../../../exceptions/components/list_search_bar';
import { caseInsensitiveSort, getSearchFilters } from './helpers';
describe('AllRulesTable Helpers', () => {

View file

@ -0,0 +1,160 @@
/*
* 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 {
Pagination as ServerPagination,
ExceptionListSchema,
ListArray,
NamespaceType,
} from '@kbn/securitysolution-io-ts-list-types';
import {
deleteExceptionListItemById,
fetchExceptionListsItemsByListIds,
updateExceptionListItem,
addExceptionListItem,
} from '@kbn/securitysolution-list-api';
import { transformInput } from '@kbn/securitysolution-list-hooks';
import type {
GetExceptionItemProps,
RuleReferences,
} from '@kbn/securitysolution-exception-list-components';
import type { HttpSetup } from '@kbn/core-http-browser';
import { findRuleExceptionReferences } from '../../detection_engine/rule_management/api/api';
import type { AddExceptionItem, DeleteExceptionItem, EditExceptionItem, FetchItems } from './types';
// Some of the APIs here are already defined in Kbn packages, need to be refactored
export const prepareFetchExceptionItemsParams = (
exceptions: ListArray | null,
list: ExceptionListSchema | null,
options?: GetExceptionItemProps | null
) => {
const { pagination, search, filters } = options || {};
let listIds: string[] = [];
let namespaceTypes: NamespaceType[] = [];
if (Array.isArray(exceptions) && exceptions.length) {
listIds = exceptions.map((excList) => excList.list_id);
namespaceTypes = exceptions.map((excList) => excList.namespace_type);
} else if (list) {
listIds = [list.list_id];
namespaceTypes = [list.namespace_type];
}
return {
listIds,
namespaceTypes,
pagination,
search,
filters,
};
};
export const fetchListExceptionItems = async ({
namespaceTypes,
listIds,
http,
pagination,
search,
}: FetchItems) => {
try {
const abortCtrl = new AbortController();
const {
pageIndex: inputPageIndex,
pageSize: inputPageSize,
totalItemCount: inputTotalItemCount,
} = pagination || {};
// TODO transform Pagination object from Frontend=>Backend & <=
const {
page: pageIndex,
per_page: pageSize,
total: totalItemCount,
data,
} = await fetchExceptionListsItemsByListIds({
filter: undefined,
http: http as HttpSetup,
listIds: listIds ?? [],
namespaceTypes: namespaceTypes ?? [],
search,
pagination: {
perPage: inputPageSize,
page: (inputPageIndex || 0) + 1,
total: inputTotalItemCount,
} as ServerPagination,
signal: abortCtrl.signal,
});
const transformedData = data.map((item) => transformInput(item));
return {
data: transformedData,
pagination: { pageIndex: pageIndex - 1, pageSize, totalItemCount },
};
} catch (error) {
throw new Error(error);
}
};
export const getExceptionItemsReferences = async (list: ExceptionListSchema) => {
try {
const abortCtrl = new AbortController();
const { references } = await findRuleExceptionReferences({
lists: [list].map((listInput) => ({
id: listInput.id,
listId: listInput.list_id,
namespaceType: listInput.namespace_type,
})),
signal: abortCtrl.signal,
});
return references.reduce<RuleReferences>((acc, reference) => {
return { ...acc, ...reference } as RuleReferences;
}, {});
} catch (error) {
throw new Error(error);
}
};
export const deleteException = async ({ id, namespaceType, http }: DeleteExceptionItem) => {
try {
const abortCtrl = new AbortController();
await deleteExceptionListItemById({
http: http as HttpSetup,
id,
namespaceType,
signal: abortCtrl.signal,
});
} catch (error) {
throw new Error(error);
}
};
export const editException = async ({ http, exception }: EditExceptionItem) => {
try {
const abortCtrl = new AbortController();
await updateExceptionListItem({
http: http as HttpSetup,
listItem: exception,
signal: abortCtrl.signal,
});
} catch (error) {
throw new Error(error);
}
};
export const addException = async ({ http, exception }: AddExceptionItem) => {
try {
const abortCtrl = new AbortController();
await addExceptionListItem({
http: http as HttpSetup,
listItem: exception,
signal: abortCtrl.signal,
});
} catch (error) {
throw new Error(error);
}
};

View file

@ -4,12 +4,4 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const READ_ONLY_BADGE_TOOLTIP = i18n.translate(
'xpack.securitySolution.exceptions.badge.readOnly.tooltip',
{
defaultMessage: 'Unable to create, edit or delete exceptions',
}
);
export * from './exception_api';

View file

@ -0,0 +1,37 @@
/*
* 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 {
CreateExceptionListItemSchema,
NamespaceType,
UpdateExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import type { Pagination } from '@elastic/eui';
import type { HttpSetup } from '@kbn/core-http-browser';
export interface FetchItems {
http: HttpSetup | undefined;
listIds: string[];
namespaceTypes: NamespaceType[];
pagination: Pagination | undefined;
search?: string;
filter?: string;
}
export interface DeleteExceptionItem {
id: string;
namespaceType: NamespaceType;
http: HttpSetup | undefined;
}
export interface EditExceptionItem {
http: HttpSetup | undefined;
exception: UpdateExceptionListItemSchema;
}
export interface AddExceptionItem {
http: HttpSetup | undefined;
exception: CreateExceptionListItemSchema;
}

View file

@ -27,7 +27,7 @@ import type { ErrorToastOptions, Toast, ToastInput } from '@kbn/core-notificatio
import { i18n as translate } from '@kbn/i18n';
import type { ListDetails } from '@kbn/securitysolution-exception-list-components';
import { useCreateSharedExceptionListWithOptionalSignal } from './use_create_shared_list';
import { useCreateSharedExceptionListWithOptionalSignal } from '../../hooks/use_create_shared_list';
import {
CREATE_SHARED_LIST_TITLE,
CREATE_SHARED_LIST_NAME_FIELD,
@ -38,7 +38,7 @@ import {
CREATE_SHARED_LIST_NAME_FIELD_PLACEHOLDER,
SUCCESS_TITLE,
getSuccessText,
} from './translations';
} from '../../translations';
export const CreateSharedListFlyout = memo(
({

View file

@ -0,0 +1,219 @@
/*
* 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 } from 'react';
import {
EuiLink,
EuiFlexGroup,
EuiFlexItem,
EuiTextColor,
EuiPanel,
EuiText,
EuiAccordion,
EuiButtonIcon,
} from '@elastic/eui';
import { css } from '@emotion/react';
import type { HttpSetup } from '@kbn/core-http-browser';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types';
import { HeaderMenu } from '@kbn/securitysolution-exception-list-components';
import styled from 'styled-components';
import { euiThemeVars } from '@kbn/ui-theme';
import { EditExceptionFlyout } from '../../../detection_engine/rule_exceptions/components/edit_exception_flyout';
import { AddExceptionFlyout } from '../../../detection_engine/rule_exceptions/components/add_exception_flyout';
import type { ExceptionListInfo } from '../../hooks/use_all_exception_lists';
import { TitleBadge } from '../title_badge';
import * as i18n from '../../translations';
import { ListExceptionItems } from '../list_exception_items';
import { useExceptionsListCard } from '../../hooks/use_exceptions_list.card';
interface ExceptionsListCardProps {
exceptionsList: ExceptionListInfo;
http: HttpSetup;
handleDelete: ({
id,
listId,
namespaceType,
}: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => () => Promise<void>;
handleExport: ({
id,
listId,
namespaceType,
}: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => () => Promise<void>;
readOnly: boolean;
}
const buttonCss = css`
// Ask KIBANA Team why Emotion is not working fully under xpack
width: 100%;
z-index: 100;
span {
cursor: pointer;
display: block;
}
`;
const ExceptionPanel = styled(EuiPanel)`
margin: -${euiThemeVars.euiSizeS} ${euiThemeVars.euiSizeM} 0 ${euiThemeVars.euiSizeM};
`;
const ListHeaderContainer = styled(EuiFlexGroup)`
padding: ${euiThemeVars.euiSizeS};
`;
export const ExceptionsListCard = memo<ExceptionsListCardProps>(
({ exceptionsList, handleDelete, handleExport, readOnly }) => {
const {
listId,
listName,
listType,
createdAt,
createdBy,
exceptions,
pagination,
ruleReferences,
toggleAccordion,
openAccordionId,
menuActionItems,
listRulesCount,
listDescription,
exceptionItemsCount,
onEditExceptionItem,
onDeleteException,
onPaginationChange,
setToggleAccordion,
exceptionViewerStatus,
showAddExceptionFlyout,
showEditExceptionFlyout,
exceptionToEdit,
onAddExceptionClick,
handleConfirmExceptionFlyout,
handleCancelExceptionItemFlyout,
} = useExceptionsListCard({
exceptionsList,
handleExport,
handleDelete,
});
return (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<EuiPanel hasShadow={false}>
<EuiAccordion
buttonProps={{ css: buttonCss }}
id={openAccordionId}
arrowDisplay="none"
onToggle={() => setToggleAccordion(!toggleAccordion)}
buttonContent={
<EuiPanel>
<ListHeaderContainer gutterSize="m" alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={toggleAccordion ? 'arrowDown' : 'arrowRight'}
aria-label="Next"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup
direction="column"
key={listId}
alignItems="flexStart"
gutterSize="none"
>
<EuiFlexItem grow>
<EuiText size="m">
<EuiLink data-test-subj="exception-list-name">{listName}</EuiLink>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiText size="xs">
<EuiTextColor color="subdued">{listDescription}</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<TitleBadge title={i18n.DATE_CREATED} badgeString={createdAt} />
</EuiFlexItem>
<EuiFlexItem>
<TitleBadge title={i18n.CREATED_BY} badgeString={createdBy} />
</EuiFlexItem>
<EuiFlexItem>
<TitleBadge title={i18n.EXCEPTIONS} badgeString={exceptionItemsCount} />
</EuiFlexItem>
<EuiFlexItem>
<TitleBadge title={i18n.RULES} badgeString={listRulesCount} />
</EuiFlexItem>
<EuiFlexItem>
<HeaderMenu
disableActions={readOnly}
dataTestSubj="sharedListOverflowCard"
actions={menuActionItems}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</ListHeaderContainer>
</EuiPanel>
}
>
<ExceptionPanel hasBorder>
<ListExceptionItems
isReadOnly={readOnly}
exceptions={exceptions}
listType={exceptionsList.type as ExceptionListTypeEnum}
pagination={pagination}
hideUtility
viewerStatus={exceptionViewerStatus}
ruleReferences={ruleReferences}
onDeleteException={onDeleteException}
onEditExceptionItem={onEditExceptionItem}
onPaginationChange={onPaginationChange}
onCreateExceptionListItem={onAddExceptionClick}
lastUpdated={null}
/>
</ExceptionPanel>
</EuiAccordion>
</EuiPanel>
</EuiFlexItem>
{showAddExceptionFlyout ? (
<AddExceptionFlyout
rules={null}
isBulkAction={false}
isEndpointItem={listType === ExceptionListTypeEnum.ENDPOINT}
sharedListToAddTo={[exceptionsList]}
onCancel={handleCancelExceptionItemFlyout}
onConfirm={handleConfirmExceptionFlyout}
data-test-subj="addExceptionItemFlyoutInSharedLists"
showAlertCloseOptions={false}
/>
) : null}
{showEditExceptionFlyout && exceptionToEdit ? (
<EditExceptionFlyout
list={exceptionsList}
itemToEdit={exceptionToEdit}
showAlertCloseOptions
openedFromListDetailPage
onCancel={handleCancelExceptionItemFlyout}
onConfirm={handleConfirmExceptionFlyout}
data-test-subj="editExceptionItemFlyoutInSharedLists"
/>
) : null}
</EuiFlexGroup>
);
}
);
ExceptionsListCard.displayName = 'ExceptionsListCard';

View file

@ -0,0 +1,51 @@
/*
* 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 from 'react';
import { render } from '@testing-library/react';
import { TestProviders } from '../../../common/mock';
import { ExceptionsUtility } from '.';
describe('ExceptionsUtility', () => {
it('it renders correct item counts', () => {
const wrapper = render(
<TestProviders>
<ExceptionsUtility
pagination={{
pageIndex: 0,
pageSize: 50,
totalItemCount: 105,
pageSizeOptions: [5, 10, 20, 50, 100],
}}
lastUpdated={1660534202}
dataTestSubj="exceptionUtility"
/>
</TestProviders>
);
expect(wrapper.getByTestId('exceptionUtilityShowingText')).toHaveTextContent(
'Showing 1-50 of 105'
);
});
it('it renders last updated message', () => {
const wrapper = render(
<TestProviders>
<ExceptionsUtility
pagination={{
pageIndex: 0,
pageSize: 50,
totalItemCount: 1,
pageSizeOptions: [5, 10, 20, 50, 100],
}}
lastUpdated={Date.now()}
dataTestSubj="exceptionUtility"
/>
</TestProviders>
);
expect(wrapper.getByTestId('exceptionUtilityLastUpdated')).toHaveTextContent('Updated now');
});
});

View file

@ -0,0 +1,104 @@
/*
* 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 from 'react';
import type { FC } from 'react';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Pagination } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import {
UtilityBar,
UtilityBarSection,
UtilityBarGroup,
UtilityBarText,
} from '../../../common/components/utility_bar';
const StyledText = styled.span`
font-weight: bold;
color: ${({ theme }) => theme.eui.euiColorDarkestShade};
`;
const MyUtilities = styled(EuiFlexGroup)`
height: 50px;
`;
const StyledCondition = styled.span`
display: inline-block !important;
vertical-align: middle !important;
`;
interface ExceptionsUtilityComponentProps {
dataTestSubj?: string;
exceptionsTitle?: string;
pagination: Pagination;
// Corresponds to last time exception items were fetched
lastUpdated: string | number | null;
}
// This component should be removed and moved to @kbn/securitysolution-exception-list-components
// once all the building components get moved
const ExceptionsUtilityComponent: FC<ExceptionsUtilityComponentProps> = ({
dataTestSubj,
pagination,
lastUpdated,
exceptionsTitle,
}) => {
const { pageSize, totalItemCount } = pagination;
return (
<MyUtilities alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText dataTestSubj={`${dataTestSubj}ShowingText`}>
<FormattedMessage
id="xpack.securitySolution.exceptions.viewer.paginationDetails"
defaultMessage="Showing {partOne} of {partTwo}"
values={{
partOne: <StyledText>{`1-${Math.min(pageSize, totalItemCount)}`}</StyledText>,
partTwo: <StyledText>{`${totalItemCount}`}</StyledText>,
}}
/>
</UtilityBarText>
{exceptionsTitle && (
<StyledText data-test-subj={`${dataTestSubj}exceptionsTitle`}>
{exceptionsTitle}
</StyledText>
)}
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" data-test-subj={`${dataTestSubj}LastUpdated`}>
<FormattedMessage
id="xpack.securitySolution.exceptions.viewer.lastUpdated"
defaultMessage="Updated {updated}"
values={{
updated: (
<StyledCondition>
<FormattedRelativePreferenceDate
value={lastUpdated}
tooltipAnchorClassName="eui-textTruncate"
/>
</StyledCondition>
),
}}
/>
</EuiText>
</EuiFlexItem>
</MyUtilities>
);
};
ExceptionsUtilityComponent.displayName = 'ExceptionsUtilityComponent';
export const ExceptionsUtility = React.memo(ExceptionsUtilityComponent);
ExceptionsUtility.displayName = 'ExceptionsUtility';

View file

@ -31,9 +31,9 @@ import type {
import type { HttpSetup } from '@kbn/core-http-browser';
import type { ToastInput, Toast, ErrorToastOptions } from '@kbn/core-notifications-browser';
import { useImportExceptionList } from './use_import_exception_list';
import { useImportExceptionList } from '../../hooks/use_import_exception_list';
import * as i18n from './translations';
import * as i18n from '../../translations';
export const ImportExceptionListFlyout = React.memo(
({

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { FC } from 'react';
import { SecurityPageName } from '../../../../common/constants';
import { getRuleDetailsTabUrl } from '../../../common/components/link_to/redirect_to_detection_engine';
import { RuleDetailTabs } from '../../../detection_engine/rule_details_ui/pages/rule_details';
import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
interface LinkAnchorProps {
referenceName: string;
referenceId: string;
external?: boolean;
}
// This component should be removed and moved to @kbn/securitysolution-exception-list-components
// once all the building components get moved
const LinkAnchor: FC<LinkAnchorProps> = ({ referenceName, referenceId, external }) => {
return (
<SecuritySolutionLinkAnchor
data-test-subj="SecuritySolutionLinkAnchor"
deepLinkId={SecurityPageName.rules}
path={getRuleDetailsTabUrl(referenceId, RuleDetailTabs.alerts)}
external={external}
>
{referenceName}
</SecuritySolutionLinkAnchor>
);
};
LinkAnchor.displayName = 'LinkAnchor';
export const ListDetailsLinkAnchor = React.memo(LinkAnchor);
ListDetailsLinkAnchor.displayName = 'ListDetailsLinkAnchor';

View file

@ -0,0 +1,100 @@
/*
* 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 from 'react';
import type { FC } from 'react';
import type {
ExceptionListItemIdentifiers,
GetExceptionItemProps,
RuleReferences,
ViewerStatus,
} from '@kbn/securitysolution-exception-list-components';
import { ExceptionItems } from '@kbn/securitysolution-exception-list-components';
import type {
ExceptionListItemSchema,
ExceptionListTypeEnum,
} from '@kbn/securitysolution-io-ts-list-types';
import type { Pagination } from '@elastic/eui';
import { FormattedDate } from '../../../common/components/formatted_date';
import { getFormattedComments } from '../../utils/ui.helpers';
import { ListDetailsLinkAnchor } from '../list_details_link_anchor';
import { ExceptionsUtility } from '../exceptions_utility';
import * as i18n from '../../translations/list_exception_items';
interface ListExceptionItemsProps {
isReadOnly: boolean;
exceptions: ExceptionListItemSchema[];
listType: ExceptionListTypeEnum;
lastUpdated: string | number | null;
pagination: Pagination;
emptyViewerTitle?: string;
emptyViewerBody?: string;
viewerStatus: ViewerStatus | '';
ruleReferences: RuleReferences;
hideUtility?: boolean;
onDeleteException: (arg: ExceptionListItemIdentifiers) => void;
onEditExceptionItem: (item: ExceptionListItemSchema) => void;
onPaginationChange: (arg: GetExceptionItemProps) => void;
onCreateExceptionListItem: () => void;
}
const ListExceptionItemsComponent: FC<ListExceptionItemsProps> = ({
isReadOnly,
exceptions,
listType,
lastUpdated,
pagination,
emptyViewerTitle,
emptyViewerBody,
viewerStatus,
ruleReferences,
hideUtility = false,
onDeleteException,
onEditExceptionItem,
onPaginationChange,
onCreateExceptionListItem,
}) => {
return (
<>
<ExceptionItems
viewerStatus={viewerStatus as ViewerStatus}
listType={listType as ExceptionListTypeEnum}
ruleReferences={ruleReferences}
isReadOnly={isReadOnly}
exceptions={exceptions}
emptyViewerTitle={emptyViewerTitle}
emptyViewerBody={emptyViewerBody}
pagination={pagination}
lastUpdated={lastUpdated}
editActionLabel={i18n.EXCEPTION_ITEM_CARD_EDIT_LABEL}
deleteActionLabel={i18n.EXCEPTION_ITEM_CARD_DELETE_LABEL}
onPaginationChange={onPaginationChange}
onEditExceptionItem={onEditExceptionItem}
onDeleteException={onDeleteException}
getFormattedComments={getFormattedComments}
securityLinkAnchorComponent={ListDetailsLinkAnchor}
formattedDateComponent={FormattedDate}
onCreateExceptionListItem={onCreateExceptionListItem}
exceptionsUtilityComponent={() =>
hideUtility ? null : (
<ExceptionsUtility
exceptionsTitle={i18n.EXCEPTION_UTILITY_TITLE}
pagination={pagination}
lastUpdated={lastUpdated}
/>
)
}
/>
</>
);
};
ListExceptionItemsComponent.displayName = 'ListExceptionItemsComponent';
export const ListExceptionItems = React.memo(ListExceptionItemsComponent);
ListExceptionItems.displayName = 'ListExceptionItems';

View file

@ -9,7 +9,7 @@ import React from 'react';
import type { EuiSearchBarProps } from '@elastic/eui';
import { EuiSearchBar } from '@elastic/eui';
import * as i18n from './translations_exceptions_table';
import * as i18n from '../../translations';
interface ExceptionListsTableSearchProps {
onSearch: (args: Parameters<NonNullable<EuiSearchBarProps['onChange']>>[0]) => void;

View file

@ -13,8 +13,8 @@ import {
UtilityBarGroup,
UtilityBarSection,
UtilityBarText,
} from '../../common/components/utility_bar';
import * as i18n from './translations_exceptions_table';
} from '../../../common/components/utility_bar';
import * as i18n from '../../translations';
interface ExceptionsTableUtilityBarProps {
onRefresh?: () => void;

View file

@ -6,9 +6,9 @@
*/
import React from 'react';
import { TestProviders } from '../../common/mock';
import { TestProviders } from '../../../common/mock';
import { render, screen, within } from '@testing-library/react';
import { ExceptionsTableUtilityBar } from './exceptions_table_utility_bar';
import { ExceptionsTableUtilityBar } from '.';
describe('ExceptionsTableUtilityBar', () => {
it('displays correct exception lists label and refresh rules action button', () => {

View file

@ -9,6 +9,7 @@ import React, { memo } from 'react';
import styled from 'styled-components';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
interface TitleBadgeProps {
title: string;
@ -17,20 +18,25 @@ interface TitleBadgeProps {
const StyledFlexItem = styled(EuiFlexItem)`
border-right: 1px solid #d3dae6;
padding: 4px 12px 4px 0;
padding: ${euiThemeVars.euiSizeXS} ${euiThemeVars.euiSizeS} ${euiThemeVars.euiSizeXS} 0;
`;
const TextContainer = styled(EuiText)`
width: max-content;
`;
export const TitleBadge = memo<TitleBadgeProps>(({ title, badgeString }) => {
return (
<EuiFlexItem style={{ flex: '1 1 auto' }}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText>{title}</EuiText>
</EuiFlexItem>
<StyledFlexItem grow={false}>
<EuiBadge>{badgeString}</EuiBadge>{' '}
</StyledFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<TextContainer grow size="xs">
{`${title}:`}
</TextContainer>
</EuiFlexItem>
<StyledFlexItem>
<EuiBadge>{badgeString}</EuiBadge>
</StyledFlexItem>
</EuiFlexGroup>
);
});

View file

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

View file

@ -8,8 +8,8 @@
import { useCallback, useEffect, useState } from 'react';
import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types';
import type { Rule } from '../../detection_engine/rule_management/logic';
import { fetchRules } from '../../detection_engine/rule_management/api/api';
import type { Rule } from '../../../detection_engine/rule_management/logic';
import { fetchRules } from '../../../detection_engine/rule_management/api/api';
export interface ExceptionListInfo extends ExceptionListSchema {
rules: Rule[];
}

View file

@ -10,7 +10,7 @@ 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';
import { SHARED_EXCEPTION_LIST_URL } from '../../../../common/constants';
export const createSharedExceptionList = async ({
name,

View file

@ -0,0 +1,172 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import type {
ExceptionListItemSchema,
NamespaceType,
} from '@kbn/securitysolution-io-ts-list-types';
import { ViewerStatus } from '@kbn/securitysolution-exception-list-components';
import { useGeneratedHtmlId } from '@elastic/eui';
import type { ExceptionListInfo } from '../use_all_exception_lists';
import { useListExceptionItems } from '../use_list_exception_items';
import * as i18n from '../../translations/list_details';
import { checkIfListCannotBeEdited } from '../../utils/list.utils';
interface ListAction {
id: string;
listId: string;
namespaceType: NamespaceType;
}
export const useExceptionsListCard = ({
exceptionsList,
handleExport,
handleDelete,
}: {
exceptionsList: ExceptionListInfo;
handleExport: ({ id, listId, namespaceType }: ListAction) => () => Promise<void>;
handleDelete: ({ id, listId, namespaceType }: ListAction) => () => Promise<void>;
}) => {
const [viewerStatus, setViewerStatus] = useState<ViewerStatus | string>(ViewerStatus.LOADING);
const [exceptionToEdit, setExceptionToEdit] = useState<ExceptionListItemSchema>();
const [showAddExceptionFlyout, setShowAddExceptionFlyout] = useState(false);
const [showEditExceptionFlyout, setShowEditExceptionFlyout] = useState(false);
const {
name: listName,
list_id: listId,
rules: listRules,
type: listType,
created_by: createdBy,
created_at: createdAt,
description: listDescription,
} = exceptionsList;
const onFinishFetchingExceptions = useCallback(() => {
setViewerStatus('');
}, [setViewerStatus]);
const onEditExceptionItem = (exception: ExceptionListItemSchema) => {
setExceptionToEdit(exception);
setShowEditExceptionFlyout(true);
};
const {
lastUpdated,
exceptionViewerStatus,
exceptions,
pagination,
ruleReferences,
fetchItems,
onDeleteException,
onPaginationChange,
} = useListExceptionItems({
list: exceptionsList,
deleteToastTitle: i18n.EXCEPTION_ITEM_DELETE_TITLE,
deleteToastBody: (name) => i18n.EXCEPTION_ITEM_DELETE_TEXT(name),
errorToastBody: i18n.EXCEPTION_ERROR_DESCRIPTION,
errorToastTitle: i18n.EXCEPTION_ERROR_TITLE,
onEditListExceptionItem: onEditExceptionItem,
onFinishFetchingExceptions,
});
useEffect(() => {
fetchItems(null, ViewerStatus.LOADING);
}, [fetchItems]);
const [toggleAccordion, setToggleAccordion] = useState(false);
const openAccordionId = useGeneratedHtmlId({ prefix: 'openAccordion' });
const listCannotBeEdited = checkIfListCannotBeEdited(exceptionsList);
const menuActionItems = useMemo(
() => [
{
key: 'Export',
icon: 'exportAction',
label: i18n.EXPORT_EXCEPTION_LIST,
onClick: (e: React.MouseEvent<Element, MouseEvent>) => {
handleExport({
id: exceptionsList.id,
listId: exceptionsList.list_id,
namespaceType: exceptionsList.namespace_type,
})();
},
},
{
key: 'Delete',
icon: 'trash',
disabled: listCannotBeEdited,
label: i18n.DELETE_EXCEPTION_LIST,
onClick: (e: React.MouseEvent<Element, MouseEvent>) => {
handleDelete({
id: exceptionsList.id,
listId: exceptionsList.list_id,
namespaceType: exceptionsList.namespace_type,
})();
},
},
],
[
exceptionsList.id,
exceptionsList.list_id,
exceptionsList.namespace_type,
handleDelete,
handleExport,
listCannotBeEdited,
]
);
// Once details Page is added all of these methods will be used from it as well
// as their own states
const onAddExceptionClick = useCallback(() => {
setShowAddExceptionFlyout(true);
}, [setShowAddExceptionFlyout]);
const handleCancelExceptionItemFlyout = () => {
setShowAddExceptionFlyout(false);
setShowEditExceptionFlyout(false);
};
const handleConfirmExceptionFlyout = useCallback(
(didExceptionChange: boolean): void => {
setShowAddExceptionFlyout(false);
setShowEditExceptionFlyout(false);
if (!didExceptionChange) return;
fetchItems();
},
[fetchItems, setShowAddExceptionFlyout, setShowEditExceptionFlyout]
);
return {
listId,
listName,
listDescription,
createdAt: new Date(createdAt).toDateString(),
createdBy,
listRulesCount: listRules.length.toString(),
exceptionItemsCount: pagination.totalItemCount.toString(),
listType,
menuActionItems,
showAddExceptionFlyout,
toggleAccordion,
openAccordionId,
viewerStatus,
exceptionToEdit,
showEditExceptionFlyout,
lastUpdated,
exceptions,
ruleReferences,
pagination,
exceptionViewerStatus,
onEditExceptionItem,
onDeleteException,
onPaginationChange,
setToggleAccordion,
onAddExceptionClick,
handleConfirmExceptionFlyout,
handleCancelExceptionItemFlyout,
};
};

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useState } from 'react';
import type { Pagination } from '@elastic/eui';
import { ViewerStatus } from '@kbn/securitysolution-exception-list-components';
import type { RuleReferences } from '@kbn/securitysolution-exception-list-components';
import type {
ExceptionListItemSchema,
ExceptionListSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { useKibana, useToasts } from '../../../common/lib/kibana';
import {
deleteException,
fetchListExceptionItems,
getExceptionItemsReferences,
prepareFetchExceptionItemsParams,
} from '../../api';
export interface UseListExceptionItemsProps {
list: ExceptionListSchema;
deleteToastTitle: string;
deleteToastBody: (exceptionName: string) => string;
errorToastTitle: string;
errorToastBody: string;
onEditListExceptionItem: (exceptionItem: ExceptionListItemSchema) => void;
onFinishFetchingExceptions?: () => void;
}
export const useListExceptionItems = ({
list,
deleteToastTitle,
deleteToastBody,
errorToastTitle,
errorToastBody,
onEditListExceptionItem,
onFinishFetchingExceptions,
}: UseListExceptionItemsProps) => {
const { services } = useKibana();
const { http } = services;
const toasts = useToasts();
const [exceptions, setExceptions] = useState<ExceptionListItemSchema[]>([]);
const [exceptionListReferences, setExceptionListReferences] = useState<RuleReferences>({});
const [pagination, setPagination] = useState<Pagination>({
pageIndex: 0,
pageSize: 0,
totalItemCount: 0,
});
const [lastUpdated, setLastUpdated] = useState<null | string | number>(null);
const [viewerStatus, setViewerStatus] = useState<ViewerStatus | ''>('');
const handleErrorStatus = useCallback(
(error: Error, errorTitle?: string, errorDescription?: string) => {
toasts?.addError(error, {
title: errorTitle || errorToastTitle,
toastMessage: errorDescription || errorToastBody,
});
setViewerStatus(ViewerStatus.ERROR);
},
[errorToastBody, errorToastTitle, toasts]
);
const getReferences = useCallback(async () => {
try {
const result: RuleReferences = await getExceptionItemsReferences(list);
setExceptionListReferences(result);
} catch (error) {
handleErrorStatus(error);
}
}, [handleErrorStatus, list, setExceptionListReferences]);
const updateViewer = useCallback((paginationResult, dataLength, viewStatus) => {
setPagination(paginationResult);
setLastUpdated(Date.now());
setTimeout(() => {
if (viewStatus === ViewerStatus.EMPTY_SEARCH)
return setViewerStatus(!dataLength ? viewStatus : '');
setViewerStatus(!dataLength ? ViewerStatus.EMPTY : '');
}, 200);
}, []);
const fetchItems = useCallback(
async (options?, viewStatus?) => {
try {
setViewerStatus(ViewerStatus.LOADING);
const { data, pagination: paginationResult } = await fetchListExceptionItems({
http,
...prepareFetchExceptionItemsParams(null, list, options),
});
setExceptions(data);
getReferences();
updateViewer(paginationResult, data.length, viewStatus);
if (typeof onFinishFetchingExceptions === 'function') onFinishFetchingExceptions();
} catch (error) {
handleErrorStatus(error);
}
},
[http, list, getReferences, updateViewer, onFinishFetchingExceptions, handleErrorStatus]
);
const onDeleteException = useCallback(
async ({ id, name, namespaceType }) => {
try {
setViewerStatus(ViewerStatus.LOADING);
await deleteException({ id, http, namespaceType });
toasts?.addSuccess({
title: deleteToastTitle,
text: typeof deleteToastBody === 'function' ? deleteToastBody(name) : '',
});
fetchItems();
} catch (error) {
handleErrorStatus(error);
}
},
[http, toasts, deleteToastTitle, deleteToastBody, fetchItems, handleErrorStatus]
);
const onEditExceptionItem = (exception: ExceptionListItemSchema) => {
if (typeof onEditListExceptionItem === 'function') onEditListExceptionItem(exception);
};
const onPaginationChange = useCallback(
async (options) => {
fetchItems(options);
},
[fetchItems]
);
return {
exceptions,
lastUpdated,
pagination,
exceptionViewerStatus: viewerStatus,
ruleReferences: exceptionListReferences,
fetchItems,
onDeleteException,
onEditExceptionItem,
onPaginationChange,
};
};

View file

@ -14,6 +14,16 @@ module.exports = {
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/security_solution/public/exceptions/**/*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/*.constants.{ts}',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/*.d.ts',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/*.config.ts',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/index.{js,ts,tsx}',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/translations/*',
'!<rootDir>/x-pack/plugins/security_solution/public/exceptions/*.translations',
],
// See: https://github.com/elastic/kibana/issues/117255, the moduleNameMapper creates mocks to avoid memory leaks from kibana core.
moduleNameMapper: {

View file

@ -1,150 +0,0 @@
/*
* 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, useState } from 'react';
import {
EuiLink,
EuiButtonIcon,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiTextColor,
EuiPanel,
EuiPopover,
EuiText,
} from '@elastic/eui';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types';
import type { ExceptionListInfo } from './use_all_exception_lists';
import { TitleBadge } from './title_badge';
import * as i18n from './translations';
interface ExceptionsListCardProps {
exceptionsList: ExceptionListInfo;
http: HttpSetup;
handleDelete: ({
id,
listId,
namespaceType,
}: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => () => Promise<void>;
handleExport: ({
id,
listId,
namespaceType,
}: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => () => Promise<void>;
readOnly: boolean;
}
export const ExceptionsListCard = memo<ExceptionsListCardProps>(
({ exceptionsList, http, handleDelete, handleExport, readOnly }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onItemActionsClick = () => setIsPopoverOpen((isOpen) => !isOpen);
const onClosePopover = () => setIsPopoverOpen(false);
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiPanel>
<EuiFlexGroup key={exceptionsList.list_id} alignItems="center" gutterSize="l">
<EuiFlexItem grow={true}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={true}>
<EuiFlexGroup direction="column" alignItems="flexStart">
<EuiFlexItem grow={false} component={'span'}>
<EuiLink data-test-subj="exception-list-name">
{exceptionsList.name.toString()}
</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">
<EuiTextColor color="subdued">{exceptionsList.description}</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<TitleBadge title={i18n.CREATED_BY} badgeString={exceptionsList.created_by} />
<TitleBadge title={i18n.CREATED_AT} badgeString={exceptionsList.created_at} />
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
data-test-subj="exceptionsListCardOverflowActions"
button={
<EuiButtonIcon
isDisabled={false}
data-test-subj="exceptionsListCardOverflowActions"
aria-label="Exception item actions menu"
iconType="boxesHorizontal"
onClick={onItemActionsClick}
/>
}
panelPaddingSize="none"
isOpen={isPopoverOpen}
closePopover={onClosePopover}
>
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem
key={'delete'}
disabled={exceptionsList.list_id === 'endpoint_list' || readOnly}
data-test-subj="exceptionsTableDeleteButton"
icon={'trash'}
onClick={() => {
onClosePopover();
handleDelete({
id: exceptionsList.id,
listId: exceptionsList.list_id,
namespaceType: exceptionsList.namespace_type,
})();
}}
>
{i18n.DELETE_EXCEPTION_LIST}
</EuiContextMenuItem>,
<EuiContextMenuItem
key={'export'}
icon={'exportAction'}
data-test-subj="exceptionsTableExportButton"
onClick={() => {
onClosePopover();
handleExport({
id: exceptionsList.id,
listId: exceptionsList.list_id,
namespaceType: exceptionsList.namespace_type,
})();
}}
>
{i18n.EXPORT_EXCEPTION_LIST}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
ExceptionsListCard.displayName = 'ExceptionsListCard';

View file

@ -1,161 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const uploadSuccessMessage = (fileName: string) =>
i18n.translate('xpack.securitySolution.lists.exceptionListImportSuccess', {
defaultMessage: "Exception list '{fileName}' was imported",
values: { fileName },
});
export const CREATED_BY = i18n.translate('xpack.securitySolution.exceptionsTable.createdBy', {
defaultMessage: 'Created By',
});
export const CREATED_AT = i18n.translate('xpack.securitySolution.exceptionsTable.createdAt', {
defaultMessage: 'Created At',
});
export const DELETE_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.exceptionsTable.deleteExceptionList',
{
defaultMessage: 'Delete Exception List',
}
);
export const EXPORT_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.exceptionsTable.exportExceptionList',
{
defaultMessage: 'Export Exception List',
}
);
export const IMPORT_EXCEPTION_LIST_HEADER = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListHeader',
{
defaultMessage: 'Import shared exception list',
}
);
export const IMPORT_EXCEPTION_LIST_BODY = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListFlyoutBody',
{
defaultMessage: 'Select shared exception lists to import',
}
);
export const IMPORT_EXCEPTION_LIST_WARNING = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListWarning',
{
defaultMessage: 'We found a pre-existing list with that id',
}
);
export const IMPORT_EXCEPTION_LIST_OVERWRITE = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListOverwrite',
{
defaultMessage: 'Overwrite the existing list',
}
);
export const IMPORT_EXCEPTION_LIST_AS_NEW_LIST = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListAsNewList',
{
defaultMessage: 'Create new list',
}
);
export const UPLOAD_SUCCESS_TITLE = i18n.translate(
'xpack.securitySolution.lists.exceptionListImportSuccessTitle',
{
defaultMessage: 'Exception list imported',
}
);
export const UPLOAD_ERROR = i18n.translate(
'xpack.securitySolution.lists.exceptionListUploadError',
{
defaultMessage: 'There was an error uploading the exception list.',
}
);
export const UPLOAD_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.exceptionListsImportButton',
{
defaultMessage: 'Import list',
}
);
export const CLOSE_FLYOUT = i18n.translate(
'xpack.securitySolution.exceptions.exceptionListsCloseImportFlyout',
{
defaultMessage: 'Close',
}
);
export const IMPORT_PROMPT = i18n.translate(
'xpack.securitySolution.exceptions.exceptionListsFilePickerPrompt',
{
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

@ -27,28 +27,28 @@ import {
import type { NamespaceType, ExceptionListFilter } from '@kbn/securitysolution-io-ts-list-types';
import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks';
import { AutoDownload } from '../../common/components/auto_download/auto_download';
import { Loader } from '../../common/components/loader';
import { useKibana } from '../../common/lib/kibana';
import { useAppToasts } from '../../common/hooks/use_app_toasts';
import { AutoDownload } from '../../../common/components/auto_download/auto_download';
import { Loader } from '../../../common/components/loader';
import { useKibana } from '../../../common/lib/kibana';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import * as i18n from './translations_exceptions_table';
import { ExceptionsTableUtilityBar } from './exceptions_table_utility_bar';
import { useAllExceptionLists } from './use_all_exception_lists';
import { ReferenceErrorModal } from '../../detections/components/value_lists_management_flyout/reference_error_modal';
import { patchRule } from '../../detection_engine/rule_management/api/api';
import { ExceptionsSearchBar } from './exceptions_search_bar';
import { getSearchFilters } from '../../detection_engine/rule_management_ui/components/rules_table/helpers';
import { useUserData } from '../../detections/components/user_info';
import { useListsConfig } from '../../detections/containers/detection_engine/lists/use_lists_config';
import { MissingPrivilegesCallOut } from '../../detections/components/callouts/missing_privileges_callout';
import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../common/endpoint/service/artifacts/constants';
import { ExceptionsListCard } from './exceptions_list_card';
import * as i18n from '../../translations/shared_list';
import { ExceptionsTableUtilityBar } from '../../components/shared_list_utilty_bar';
import { useAllExceptionLists } from '../../hooks/use_all_exception_lists';
import { ReferenceErrorModal } from '../../../detections/components/value_lists_management_flyout/reference_error_modal';
import { patchRule } from '../../../detection_engine/rule_management/api/api';
import { ExceptionsSearchBar } from '../../components/list_search_bar';
import { getSearchFilters } from '../../../detection_engine/rule_management_ui/components/rules_table/helpers';
import { useUserData } from '../../../detections/components/user_info';
import { useListsConfig } from '../../../detections/containers/detection_engine/lists/use_lists_config';
import { MissingPrivilegesCallOut } from '../../../detections/components/callouts/missing_privileges_callout';
import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../common/endpoint/service/artifacts/constants';
import { ExceptionsListCard } from '../../components/exceptions_list_card';
import { ImportExceptionListFlyout } from './import_exceptions_list_flyout';
import { CreateSharedListFlyout } from './create_shared_exception_list';
import { ImportExceptionListFlyout } from '../../components/import_exceptions_list_flyout';
import { CreateSharedListFlyout } from '../../components/create_shared_exception_list';
import { AddExceptionFlyout } from '../../detection_engine/rule_exceptions/components/add_exception_flyout';
import { AddExceptionFlyout } from '../../../detection_engine/rule_exceptions/components/add_exception_flyout';
export type Func = () => Promise<void>;
@ -68,7 +68,7 @@ const exceptionReferenceModalInitialState: ReferenceModalState = {
listNamespaceType: 'single',
};
export const ExceptionListsTable = React.memo(() => {
export const SharedLists = React.memo(() => {
const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData();
const { loading: listsConfigLoading } = useListsConfig();
@ -304,6 +304,7 @@ export const ExceptionListsTable = React.memo(() => {
iconSide="right"
onClick={onRowSizeButtonClick}
>
{/* TODO move to translations */}
{`Rows per page: ${rowSize}`}
</EuiButtonEmpty>
);
@ -479,9 +480,8 @@ export const ExceptionListsTable = React.memo(() => {
totalExceptionLists={exceptionListsWithRuleRefs.length}
onRefresh={handleRefresh}
/>
<EuiSpacer size="m" />
{exceptionListsWithRuleRefs.length > 0 && canUserCRUD !== null && canUserREAD !== null && (
<React.Fragment data-test-subj="exceptionsTable">
<div data-test-subj="exceptionsTable">
{exceptionListsWithRuleRefs.map((excList) => (
<ExceptionsListCard
key={excList.list_id}
@ -493,7 +493,7 @@ export const ExceptionListsTable = React.memo(() => {
handleExport={handleExport}
/>
))}
</React.Fragment>
</div>
)}
</>
)}
@ -546,4 +546,4 @@ export const ExceptionListsTable = React.memo(() => {
);
});
ExceptionListsTable.displayName = 'ExceptionListsTable';
SharedLists.displayName = 'SharedLists';

View file

@ -6,21 +6,21 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { TestProviders } from '../../common/mock';
import { TestProviders } from '../../../common/mock';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
import { useUserData } from '../../detections/components/user_info';
import { useUserData } from '../../../detections/components/user_info';
import { ExceptionListsTable } from './exceptions_table';
import { SharedLists } from '.';
import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks';
import { useAllExceptionLists } from './use_all_exception_lists';
import { useAllExceptionLists } from '../../hooks/use_all_exception_lists';
import { useHistory } from 'react-router-dom';
import { generateHistoryMock } from '../../common/utils/route/mocks';
import { generateHistoryMock } from '../../../common/utils/route/mocks';
import { fireEvent, render, waitFor } from '@testing-library/react';
jest.mock('../../detections/components/user_info');
jest.mock('../../common/lib/kibana');
jest.mock('./use_all_exception_lists');
jest.mock('../../../detections/components/user_info');
jest.mock('../../../common/utils/route/mocks');
jest.mock('../../hooks/use_all_exception_lists');
jest.mock('@kbn/securitysolution-list-hooks');
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
@ -39,11 +39,11 @@ jest.mock('@kbn/i18n-react', () => {
};
});
jest.mock('../../detections/containers/detection_engine/lists/use_lists_config', () => ({
jest.mock('../../../detections/containers/detection_engine/lists/use_lists_config', () => ({
useListsConfig: jest.fn().mockReturnValue({ loading: false }),
}));
describe('ExceptionListsTable', () => {
describe('SharedLists', () => {
const mockHistory = generateHistoryMock();
const exceptionList1 = getExceptionListSchemaMock();
const exceptionList2 = { ...getExceptionListSchemaMock(), list_id: 'not_endpoint_list', id: '2' };
@ -91,21 +91,18 @@ describe('ExceptionListsTable', () => {
});
it('renders delete option as disabled if list is "endpoint_list"', async () => {
const wrapper = mount(
const wrapper = render(
<TestProviders>
<ExceptionListsTable />
<SharedLists />
</TestProviders>
);
const allMenuActions = wrapper.getAllByTestId('sharedListOverflowCardButtonIcon');
fireEvent.click(allMenuActions[0]);
wrapper
.find('[data-test-subj="exceptionsListCardOverflowActions"] button')
.at(0)
.simulate('click');
expect(wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button')).toHaveLength(1);
expect(
wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled')
).toBeTruthy();
await waitFor(() => {
const allDeleteActions = wrapper.getAllByTestId('sharedListOverflowCardActionItemDelete');
expect(allDeleteActions[0]).toBeDisabled();
});
});
it('renders delete option as disabled if user is read only', async () => {
@ -117,17 +114,19 @@ describe('ExceptionListsTable', () => {
},
]);
const wrapper = mount(
const wrapper = render(
<TestProviders>
<ExceptionListsTable />
<SharedLists />
</TestProviders>
);
wrapper
.find('[data-test-subj="exceptionsListCardOverflowActions"] button')
.at(0)
.simulate('click');
expect(
wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled')
).toBeTruthy();
const allMenuActions = wrapper.getAllByTestId('sharedListOverflowCardButtonIcon');
fireEvent.click(allMenuActions[1]);
await waitFor(() => {
const allDeleteActions = wrapper.queryAllByTestId('sharedListOverflowCardActionItemDelete');
expect(allDeleteActions).toEqual([]);
const allExportActions = wrapper.queryAllByTestId('sharedListOverflowCardActionItemExport');
expect(allExportActions).toEqual([]);
});
});
});

View file

@ -11,7 +11,7 @@ import { Route } from '@kbn/kibana-react-plugin/public';
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import * as i18n from './translations';
import { EXCEPTIONS_PATH, SecurityPageName } from '../../common/constants';
import { ExceptionListsTable } from './manage_exceptions/exceptions_table';
import { SharedLists } from './pages/shared_lists';
import { SpyRoute } from '../common/utils/route/spy_routes';
import { NotFoundPage } from '../app/404';
import { useReadonlyHeader } from '../use_readonly_header';
@ -20,7 +20,7 @@ import { PluginTemplateWrapper } from '../common/components/plugin_template_wrap
const ExceptionsRoutes = () => (
<PluginTemplateWrapper>
<TrackApplicationView viewId={SecurityPageName.exceptions}>
<ExceptionListsTable />
<SharedLists />
<SpyRoute pageName={SecurityPageName.exceptions} />
</TrackApplicationView>
</PluginTemplateWrapper>

View file

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

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const EXCEPTION_LIST_EMPTY_VIEWER_TITLE = i18n.translate(
'xpack.securitySolution.exception.list.empty.viewer_title',
{
defaultMessage: 'Create exceptions to this list',
}
);
export const EXCEPTION_LIST_EMPTY_VIEWER_BODY = (listName: string) =>
i18n.translate('xpack.securitySolution.exception.list.empty.viewer_body', {
values: { listName },
defaultMessage:
'There is no exception in your [{listName}]. Create rule exceptions to this list.',
});
export const EXCEPTION_LIST_EMPTY_VIEWER_BUTTON = i18n.translate(
'xpack.securitySolution.exception.list.empty.viewer_button',
{
defaultMessage: 'Create rule exception',
}
);
export const EXCEPTION_LIST_EMPTY_SEARCH_BAR_BUTTON = i18n.translate(
'xpack.securitySolution.exception.list.search_bar_button',
{
defaultMessage: 'Add rule exception to list',
}
);
export const EXCEPTION_LIST_SEARCH_ERROR_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.list.exceptionItemSearchErrorTitle',
{
defaultMessage: 'Error searching',
}
);
export const EXCEPTION_LIST_SEARCH_ERROR_BODY = i18n.translate(
'xpack.securitySolution.exceptions.list.exceptionItemSearchErrorBody',
{
defaultMessage: 'An error occurred searching for exception items. Please try again.',
}
);
export const EXCEPTION_ERROR_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.list.exceptionItemsFetchError',
{
defaultMessage: 'Unable to load exception items',
}
);
export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate(
'xpack.securitySolution.exceptions.list.exceptionItemsFetchErrorDescription',
{
defaultMessage:
'There was an error loading the exception items. Contact your administrator for help.',
}
);
export const EXCEPTION_ITEM_DELETE_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.list.exception.item.card.exceptionItemDeleteSuccessTitle',
{
defaultMessage: 'Exception deleted',
}
);
export const EXCEPTION_ITEM_DELETE_TEXT = (itemName: string) =>
i18n.translate(
'xpack.securitySolution.exceptions.list.exception.item.card.exceptionItemDeleteSuccessText',
{
values: { itemName },
defaultMessage: '"{itemName}" deleted successfully.',
}
);
export const EXCEPTION_LIST_EXPORTED_SUCCESSFULLY = (listName: string) =>
i18n.translate('xpack.securitySolution.exceptions.list.exported_successfully', {
values: { listName },
defaultMessage: '{listName} exported successfully',
});
export const EXCEPTION_LIST_DELETED_SUCCESSFULLY = (listName: string) =>
i18n.translate('xpack.securitySolution.exceptions.list.deleted_successfully', {
values: { listName },
defaultMessage: '{listName} deleted successfully',
});
export const MANAGE_RULES_CANCEL = i18n.translate(
'xpack.securitySolution.exceptions.list.manage_rules_cancel',
{
defaultMessage: 'Cancel',
}
);
export const MANAGE_RULES_SAVE = i18n.translate(
'xpack.securitySolution.exceptions.list.manage_rules_save',
{
defaultMessage: 'Save',
}
);
export const MANAGE_RULES_HEADER = i18n.translate(
'xpack.securitySolution.exceptions.list.manage_rules_header',
{
defaultMessage: 'Manege rules',
}
);
export const MANAGE_RULES_DESCRIPTION = i18n.translate(
'xpack.securitySolution.exceptions.list.manage_rules_description',
{
defaultMessage: 'Link or unlink rules to this exception list.',
}
);
export const DELETE_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.exceptionsTable.deleteExceptionList',
{
defaultMessage: 'Delete Exception List',
}
);
export const EXPORT_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.exceptionsTable.exportExceptionList',
{
defaultMessage: 'Export Exception List',
}
);

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const EXCEPTION_ITEM_CARD_EDIT_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.list.exception.item.card.edit.label',
{
defaultMessage: 'Edit rule exception',
}
);
export const EXCEPTION_ITEM_CARD_DELETE_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.list.exception.item.card.delete.label',
{
defaultMessage: 'Delete rule exception',
}
);
export const EXCEPTION_UTILITY_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.list.utility.title',
{
defaultMessage: 'rule exceptions',
}
);

View file

@ -202,10 +202,74 @@ export const IMPORT_EXCEPTION_LIST_BUTTON = i18n.translate(
}
);
export const CREATE_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.manageExceptions.create',
export const IMPORT_EXCEPTION_LIST_HEADER = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListFlyoutHeader',
{
defaultMessage: 'Create',
defaultMessage: 'Import shared exception list',
}
);
export const IMPORT_EXCEPTION_LIST_BODY = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListFlyoutBody',
{
defaultMessage: 'Select shared exception lists to import',
}
);
export const IMPORT_EXCEPTION_LIST_WARNING = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListWarning',
{
defaultMessage: 'We found a pre-existing list with that id',
}
);
export const IMPORT_EXCEPTION_LIST_OVERWRITE = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListOverwrite',
{
defaultMessage: 'Overwrite the existing list',
}
);
export const IMPORT_EXCEPTION_LIST_AS_NEW_LIST = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionListAsNewList',
{
defaultMessage: 'Create new list',
}
);
export const READ_ONLY_BADGE_TOOLTIP = i18n.translate(
'xpack.securitySolution.exceptions.badge.readOnly.tooltip',
{
defaultMessage: 'Unable to create, edit or delete exceptions',
}
);
export const CLOSE_FLYOUT = i18n.translate(
'xpack.securitySolution.exceptions.exceptionListsCloseImportFlyout',
{
defaultMessage: 'Close',
}
);
export const IMPORT_PROMPT = i18n.translate(
'xpack.securitySolution.exceptions.exceptionListsFilePickerPrompt',
{
defaultMessage: 'Select or drag and drop multiple files',
}
);
export const RULES = i18n.translate('xpack.securitySolution.exceptionsTable.rulesCountLabel', {
defaultMessage: 'Rules',
});
export const CREATED_BY = i18n.translate('xpack.securitySolution.exceptionsTable.createdBy', {
defaultMessage: 'Created By',
});
export const DATE_CREATED = i18n.translate('xpack.securitySolution.exceptionsTable.createdAt', {
defaultMessage: 'Date created',
});
export const EXCEPTIONS = i18n.translate(
'xpack.securitySolution.exceptionsTable.exceptionsCountLabel',
{
defaultMessage: 'Exceptions',
}
);
@ -222,3 +286,58 @@ export const CREATE_BUTTON_ITEM_BUTTON = i18n.translate(
defaultMessage: 'create exception item',
}
);
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

@ -0,0 +1,13 @@
/*
* 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 { listIDsCannotBeEdited } from '../config';
import type { ExceptionListInfo } from '../hooks/use_all_exception_lists';
export const checkIfListCannotBeEdited = (list: ExceptionListInfo) => {
return !!listIDsCannotBeEdited.find((id) => id === list.list_id);
};

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const COMMENTS_SHOW = (comments: number) =>
i18n.translate('xpack.securitySolution.exceptions.showCommentsLabel', {
values: { comments },
defaultMessage: 'Show ({comments}) {comments, plural, =1 {Comment} other {Comments}}',
});
export const COMMENTS_HIDE = (comments: number) =>
i18n.translate('xpack.securitySolution.exceptions.hideCommentsLabel', {
values: { comments },
defaultMessage: 'Hide ({comments}) {comments, plural, =1 {Comment} other {Comments}}',
});
export const COMMENT_EVENT = i18n.translate('xpack.securitySolution.exceptions.commentEventLabel', {
defaultMessage: 'added a comment',
});
export const OPERATING_SYSTEM_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.operatingSystemFullLabel',
{
defaultMessage: 'Operating System',
}
);
export const ADD_TO_ENDPOINT_LIST = i18n.translate(
'xpack.securitySolution.exceptions.viewer.addToEndpointListLabel',
{
defaultMessage: 'Add endpoint exception',
}
);
export const ADD_TO_DETECTIONS_LIST = i18n.translate(
'xpack.securitySolution.exceptions.viewer.addToDetectionsListLabel',
{
defaultMessage: 'Add rule exception',
}
);
export const ADD_COMMENT_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.exceptions.viewer.addCommentPlaceholder',
{
defaultMessage: 'Add a new comment...',
}
);
export const ADD_TO_CLIPBOARD = i18n.translate(
'xpack.securitySolution.exceptions.viewer.addToClipboard',
{
defaultMessage: 'Comment',
}
);
export const CLEAR_EXCEPTIONS_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.clearExceptionsLabel',
{
defaultMessage: 'Remove Exception List',
}
);
export const ADD_EXCEPTION_FETCH_404_ERROR = (listId: string) =>
i18n.translate('xpack.securitySolution.exceptions.fetch404Error', {
values: { listId },
defaultMessage:
'The associated exception list ({listId}) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.',
});
export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate(
'xpack.securitySolution.exceptions.fetchError',
{
defaultMessage: 'Error fetching exception list',
}
);
export const ERROR = i18n.translate('xpack.securitySolution.exceptions.errorLabel', {
defaultMessage: 'Error',
});
export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.cancelLabel', {
defaultMessage: 'Cancel',
});
export const MODAL_ERROR_ACCORDION_TEXT = i18n.translate(
'xpack.securitySolution.exceptions.modalErrorAccordionText',
{
defaultMessage: 'Show rule reference information:',
}
);
export const DISASSOCIATE_LIST_SUCCESS = (id: string) =>
i18n.translate('xpack.securitySolution.exceptions.disassociateListSuccessText', {
values: { id },
defaultMessage: 'Exception list ({id}) has successfully been removed',
});
export const DISASSOCIATE_EXCEPTION_LIST_ERROR = i18n.translate(
'xpack.securitySolution.exceptions.disassociateExceptionListError',
{
defaultMessage: 'Failed to remove exception list',
}
);
export const OPERATING_SYSTEM_WINDOWS = i18n.translate(
'xpack.securitySolution.exceptions.operatingSystemWindows',
{
defaultMessage: 'Windows',
}
);
export const OPERATING_SYSTEM_MAC = i18n.translate(
'xpack.securitySolution.exceptions.operatingSystemMac',
{
defaultMessage: 'macOS',
}
);
export const OPERATING_SYSTEM_WINDOWS_AND_MAC = i18n.translate(
'xpack.securitySolution.exceptions.operatingSystemWindowsAndMac',
{
defaultMessage: 'Windows and macOS',
}
);
export const OPERATING_SYSTEM_LINUX = i18n.translate(
'xpack.securitySolution.exceptions.operatingSystemLinux',
{
defaultMessage: 'Linux',
}
);
export const ERROR_FETCHING_REFERENCES_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.fetchingReferencesErrorToastTitle',
{
defaultMessage: 'Error fetching exception references',
}
);

View file

@ -0,0 +1,55 @@
/*
* 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 React from 'react';
import { mount } from 'enzyme';
import moment from 'moment-timezone';
import { getFormattedComments } from './ui.helpers';
import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('123'),
}));
describe('Exception helpers', () => {
beforeEach(() => {
moment.tz.setDefault('UTC');
});
afterEach(() => {
moment.tz.setDefault('Browser');
});
describe('#getFormattedComments', () => {
test('it returns formatted comment object with username and timestamp', () => {
const payload = getCommentsArrayMock();
const result = getFormattedComments(payload);
expect(result[0].username).toEqual('some user');
expect(result[0].timestamp).toEqual('on Apr 20th 2020 @ 15:25:31');
});
test('it returns formatted timeline icon with comment users initial', () => {
const payload = getCommentsArrayMock();
const result = getFormattedComments(payload);
const wrapper = mount<React.ReactElement>(result[0].timelineAvatar as React.ReactElement);
expect(wrapper.text()).toEqual('SU');
});
test('it returns comment text', () => {
const payload = getCommentsArrayMock();
const result = getFormattedComments(payload);
const wrapper = mount<React.ReactElement>(result[0].children as React.ReactElement);
expect(wrapper.text()).toEqual('some old comment');
});
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 from 'react';
import type { EuiCommentProps } from '@elastic/eui';
import { EuiText, EuiAvatar } from '@elastic/eui';
import type { CommentsArray } from '@kbn/securitysolution-io-ts-list-types';
import moment from 'moment';
import * as i18n from './translations';
import { WithCopyToClipboard } from '../../common/lib/clipboard/with_copy_to_clipboard';
/**
* Formats ExceptionItem.comments into EuiCommentList format
*
* @param comments ExceptionItem.comments
*/
export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] =>
comments.map((commentItem) => ({
username: commentItem.created_by,
timestamp: moment(commentItem.created_at).format('on MMM Do YYYY @ HH:mm:ss'),
event: i18n.COMMENT_EVENT,
timelineAvatar: <EuiAvatar size="l" name={commentItem.created_by.toUpperCase()} />,
children: <EuiText size="s">{commentItem.comment}</EuiText>,
actions: (
<WithCopyToClipboard
data-test-subj="copy-to-clipboard"
text={commentItem.comment}
titleSummary={i18n.ADD_TO_CLIPBOARD}
/>
),
}));