[Security Solution] [Exceptions] Updates the exceptions list table to match mockups (#142289)

Co-authored-by: Gloria Hornero <gloria.hornero@elastic.co>
This commit is contained in:
Devin W. Hurley 2022-11-02 11:41:48 -04:00 committed by GitHub
parent 2e23aa0c31
commit c1070e63a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 922 additions and 398 deletions

View file

@ -13,6 +13,7 @@ import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts
describe('importQuerySchema', () => {
test('it should validate proper schema', () => {
const payload: ImportQuerySchema = {
as_new_list: false,
overwrite: true,
overwrite_exceptions: true,
};
@ -26,6 +27,7 @@ describe('importQuerySchema', () => {
test('it should NOT validate a non boolean value for "overwrite"', () => {
const payload: Omit<ImportQuerySchema, 'overwrite'> & { overwrite: string } = {
as_new_list: false,
overwrite: 'wrong',
overwrite_exceptions: true,
};
@ -43,6 +45,7 @@ describe('importQuerySchema', () => {
const payload: Omit<ImportQuerySchema, 'overwrite_exceptions'> & {
overwrite_exceptions: string;
} = {
as_new_list: false,
overwrite: true,
overwrite_exceptions: 'wrong',
};

View file

@ -14,14 +14,16 @@ export const importQuerySchema = t.exact(
t.partial({
overwrite: DefaultStringBooleanFalse,
overwrite_exceptions: DefaultStringBooleanFalse,
as_new_list: DefaultStringBooleanFalse,
})
);
export type ImportQuerySchema = t.TypeOf<typeof importQuerySchema>;
export type ImportQuerySchemaDecoded = Omit<
ImportQuerySchema,
'overwrite' | 'overwrite_exceptions'
'overwrite' | 'overwrite_exceptions' | 'as_new_list'
> & {
overwrite: boolean;
overwrite_exceptions: boolean;
as_new_list: boolean;
};

View file

@ -58,6 +58,7 @@ export const importExceptionsRoute = (router: ListsPluginRouter, config: ConfigT
const importsSummary = await exceptionListsClient.importExceptionListAndItems({
exceptionsToImport: request.body.file,
generateNewListId: request.query.as_new_list,
maxExceptionsImportSize: config.maxExceptionsImportSize,
overwrite: request.query.overwrite,
});

View file

@ -299,6 +299,7 @@ describe('exception_list_client', () => {
(): ReturnType<ExceptionListClient['importExceptionListAndItems']> => {
return exceptionListClient.importExceptionListAndItems({
exceptionsToImport: toReadable([getExceptionListItemSchemaMock()]),
generateNewListId: false,
maxExceptionsImportSize: 10_000,
overwrite: true,
});

View file

@ -984,6 +984,7 @@ export class ExceptionListClient {
exceptionsToImport,
maxExceptionsImportSize,
overwrite,
generateNewListId,
}: ImportExceptionListAndItemsOptions): Promise<ImportExceptionsResponseSchema> => {
const { savedObjectsClient, user } = this;
@ -1004,6 +1005,7 @@ export class ExceptionListClient {
return importExceptions({
exceptions: parsedObjects,
generateNewListId,
overwrite,
savedObjectsClient,
user,
@ -1038,6 +1040,7 @@ export class ExceptionListClient {
return importExceptions({
exceptions: parsedObjects,
generateNewListId: false,
overwrite,
savedObjectsClient,
user,

View file

@ -502,6 +502,7 @@ export interface ImportExceptionListAndItemsOptions {
maxExceptionsImportSize: number;
/** whether or not to overwrite an exception list with imported list if a matching list_id found */
overwrite: boolean;
generateNewListId: boolean;
}
/**

View file

@ -56,6 +56,7 @@ describe('import_exception_list_and_items', () => {
getImportExceptionsListSchemaMock('test_list_id'),
getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id'),
]),
generateNewListId: false,
maxExceptionsImportSize: 10000,
overwrite: false,
});
@ -82,6 +83,7 @@ describe('import_exception_list_and_items', () => {
getImportExceptionsListSchemaMock('test_list_id'),
getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id'),
]),
generateNewListId: false,
maxExceptionsImportSize: 10000,
overwrite: false,
});
@ -102,6 +104,7 @@ describe('import_exception_list_and_items', () => {
getImportExceptionsListSchemaMock('test_list_id'),
getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id'),
]),
generateNewListId: false,
maxExceptionsImportSize: 10000,
overwrite: false,
});

View file

@ -18,6 +18,7 @@ import {
import { createPromiseFromStreams } from '@kbn/utils';
import { SavedObjectsClientContract } from '@kbn/core/server';
import { chunk } from 'lodash/fp';
import uuid from 'uuid';
import { importExceptionLists } from './utils/import/import_exception_lists';
import { importExceptionListItems } from './utils/import/import_exception_list_items';
@ -49,6 +50,7 @@ export interface ImportDataResponse {
interface ImportExceptionListAndItemsOptions {
exceptions: PromiseFromStreams;
overwrite: boolean;
generateNewListId: boolean;
savedObjectsClient: SavedObjectsClientContract;
user: string;
}
@ -90,6 +92,7 @@ export const importExceptionsAsStream = async ({
return importExceptions({
exceptions: parsedObjects,
generateNewListId: false,
overwrite,
savedObjectsClient,
user,
@ -99,14 +102,44 @@ export const importExceptionsAsStream = async ({
export const importExceptions = async ({
exceptions,
overwrite,
generateNewListId,
savedObjectsClient,
user,
}: ImportExceptionListAndItemsOptions): Promise<ImportExceptionsResponseSchema> => {
let exceptionsToValidate = exceptions;
if (generateNewListId) {
// we need to generate a new list id and update the old list id references
// in each list item to point to the new list id
exceptionsToValidate = exceptions.lists.reduce(
(acc, exceptionList) => {
if (exceptionList instanceof Error) {
return { items: [...acc.items], lists: [...acc.lists] };
}
const newListId = uuid.v4();
return {
items: [
...acc.items,
...exceptions.items
.filter(
(item) =>
!(item instanceof Error) &&
!(exceptionList instanceof Error) &&
item?.list_id === exceptionList?.list_id
)
.map((item) => ({ ...item, list_id: newListId })),
],
lists: [...acc.lists, { ...exceptionList, list_id: newListId }],
};
},
{ items: [], lists: [] } as PromiseFromStreams
);
}
// removal of duplicates
const [exceptionListDuplicateErrors, uniqueExceptionLists] =
getTupleErrorsAndUniqueExceptionLists(exceptions.lists);
getTupleErrorsAndUniqueExceptionLists(exceptionsToValidate.lists);
const [exceptionListItemsDuplicateErrors, uniqueExceptionListItems] =
getTupleErrorsAndUniqueExceptionListItems(exceptions.items);
getTupleErrorsAndUniqueExceptionListItems(exceptionsToValidate.items);
// chunking of validated import stream
const chunkParsedListObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueExceptionLists);
@ -115,6 +148,7 @@ export const importExceptions = async ({
// where the magic happens - purposely importing parent exception
// containers first, items second
const importExceptionListsResponse = await importExceptionLists({
generateNewListId,
isOverwrite: overwrite,
listsChunks: chunkParsedListObjects,
savedObjectsClient,

View file

@ -26,11 +26,13 @@ import { sortImportResponses } from './sort_import_responses';
*/
export const importExceptionLists = async ({
isOverwrite,
generateNewListId,
listsChunks,
savedObjectsClient,
user,
}: {
isOverwrite: boolean;
generateNewListId: boolean;
listsChunks: ImportExceptionListSchemaDecoded[][];
savedObjectsClient: SavedObjectsClientContract;
user: string;
@ -56,6 +58,7 @@ export const importExceptionLists = async ({
const { errors, listItemsToDelete, listsToCreate, listsToUpdate } =
sortExceptionListsToUpdateOrCreate({
existingLists: foundLists,
generateNewListId,
isOverwrite,
lists: listChunk,
user,

View file

@ -169,7 +169,7 @@ describe('sort_exception_lists_items_to_create_update', () => {
});
});
it('assigns error if matching item_id found but differing list_id', () => {
it('assigns no error if matching item_id found but differing list_id', () => {
const result = sortExceptionItemsToUpdateOrCreate({
existingItems: {
'item-id-1': {
@ -185,17 +185,7 @@ describe('sort_exception_lists_items_to_create_update', () => {
});
expect(result).toEqual({
errors: [
{
error: {
message:
'Error trying to update item_id: "item-id-1" and list_id: "list-id-1". The item already exists under list_id: list-id-2',
status_code: 409,
},
item_id: 'item-id-1',
list_id: 'list-id-1',
},
],
errors: [],
itemsToCreate: [],
itemsToUpdate: [],
});

View file

@ -131,31 +131,6 @@ export const sortExceptionItemsToUpdateOrCreate = ({
type: savedObjectType,
},
];
} else {
// If overwrite is true, the list parent container is deleted first along
// with its items, so to get here would mean the user hit a bit of an odd scenario.
// Sample scenario would be as follows:
// In system we have:
// List A ---> with item list_item_id
// Import is:
// List A ---> with item list_item_id_1
// List B ---> with item list_item_id_1
// If we just did an update of the item, we would overwrite
// list_item_id_1 of List A, which would be weird behavior
// What happens:
// List A and items are deleted and recreated
// List B is created, but list_item_id_1 already exists under List A and user warned
results.errors = [
...results.errors,
{
error: {
message: `Error trying to update item_id: "${itemId}" and list_id: "${listId}". The item already exists under list_id: ${existingItems[itemId].list_id}`,
status_code: 409,
},
item_id: itemId,
list_id: listId,
},
];
}
} else if (existingItems[itemId] != null) {
results.errors = [

View file

@ -25,6 +25,7 @@ describe('sort_exception_lists_to_create_update', () => {
it('assigns list to create if its list_id does not match an existing one', () => {
const result = sortExceptionListsToUpdateOrCreate({
existingLists: {},
generateNewListId: false,
isOverwrite: false,
lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')],
user: 'elastic',
@ -66,6 +67,7 @@ describe('sort_exception_lists_to_create_update', () => {
existingLists: {
'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' },
},
generateNewListId: false,
isOverwrite: false,
lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')],
user: 'elastic',
@ -93,6 +95,7 @@ describe('sort_exception_lists_to_create_update', () => {
it('assigns list to be created if its list_id does not match an existing one', () => {
const result = sortExceptionListsToUpdateOrCreate({
existingLists: {},
generateNewListId: false,
isOverwrite: true,
lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')],
user: 'elastic',
@ -134,6 +137,7 @@ describe('sort_exception_lists_to_create_update', () => {
existingLists: {
'list-id-1': { ...getExceptionListSchemaMock(), list_id: 'list-id-1' },
},
generateNewListId: false,
isOverwrite: true,
lists: [getImportExceptionsListSchemaDecodedMock('list-id-1')],
user: 'elastic',

View file

@ -21,11 +21,13 @@ export const sortExceptionListsToUpdateOrCreate = ({
lists,
existingLists,
isOverwrite,
generateNewListId,
user,
}: {
lists: ImportExceptionListSchemaDecoded[];
existingLists: Record<string, ExceptionListSchema>;
isOverwrite: boolean;
generateNewListId: boolean;
user: string;
}): {
errors: BulkErrorSchema[];
@ -102,6 +104,27 @@ export const sortExceptionListsToUpdateOrCreate = ({
type: savedObjectType,
},
];
} else if (existingLists[listId] != null && generateNewListId) {
const attributes: ExceptionListSoSchema = {
...existingLists[listId],
comments: undefined,
created_at: dateNow,
created_by: user,
description,
entries: undefined,
immutable: false,
item_id: undefined,
list_type: 'list',
tie_breaker_id: uuid.v4(),
updated_by: user,
};
results.listsToCreate = [
...results.listsToCreate,
{
attributes,
type: savedObjectType,
},
];
} else if (existingLists[listId] != null) {
results.errors = [
...results.errors,

View file

@ -20,6 +20,7 @@ import {
searchForExceptionList,
waitForExceptionsTableToBeLoaded,
clearSearchSelection,
expandExceptionActions,
} from '../../../tasks/exceptions_table';
import {
EXCEPTIONS_TABLE_DELETE_BTN,
@ -67,16 +68,11 @@ describe('Exceptions Table', () => {
);
visitWithoutDateRange(EXCEPTIONS_URL);
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3');
});
it('Exports exception list', function () {
cy.intercept(/(\/api\/exception_lists\/_export)/).as('export');
visitWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
exportExceptionList();
@ -91,7 +87,6 @@ describe('Exceptions Table', () => {
});
it('Filters exception lists on search', () => {
visitWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
// Using cy.contains because we do not care about the exact text,
@ -142,7 +137,6 @@ describe('Exceptions Table', () => {
});
it('Deletes exception list without rule reference', () => {
visitWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
// Using cy.contains because we do not care about the exact text,
@ -189,6 +183,7 @@ describe('Exceptions Table - read only', () => {
});
it('Delete icon is not shown', () => {
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('not.exist');
expandExceptionActions();
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('be.disabled');
});
});

View file

@ -34,7 +34,10 @@ export const ENTRY_DELETE_BTN = '[data-test-subj="builderItemEntryDeleteButton"]
export const CANCEL_BTN = '[data-test-subj="cancelExceptionAddButton"]';
export const EXCEPTIONS_TABLE = '[data-test-subj="exceptions-table"]';
export const EXCEPTIONS_OVERFLOW_ACTIONS_BTN =
'[data-test-subj="exceptionsListCardOverflowActions"]';
export const EXCEPTIONS_TABLE = '[data-test-subj="pageContainer"]';
export const EXCEPTIONS_TABLE_SEARCH = '[data-test-subj="exceptionsHeaderSearchInput"]';
@ -47,7 +50,7 @@ export const EXCEPTIONS_TABLE_EXPORT_BTN = '[data-test-subj="exceptionsTableExpo
export const EXCEPTIONS_TABLE_SEARCH_CLEAR =
'[data-test-subj="allExceptionListsPanel"] button.euiFormControlLayoutClearButton';
export const EXCEPTIONS_TABLE_LIST_NAME = '[data-test-subj="exceptionsTableName"]';
export const EXCEPTIONS_TABLE_LIST_NAME = '[data-test-subj="exception-list-name"]';
export const EXCEPTIONS_TABLE_MODAL = '[data-test-subj="referenceErrorModal"]';

View file

@ -13,11 +13,34 @@ import {
EXCEPTIONS_TABLE_MODAL,
EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN,
EXCEPTIONS_TABLE_EXPORT_BTN,
EXCEPTIONS_OVERFLOW_ACTIONS_BTN,
} from '../screens/exceptions';
export const waitForExceptionsTableToBeLoaded = () => {
cy.get(EXCEPTIONS_TABLE).should('exist');
cy.get(EXCEPTIONS_TABLE_SEARCH).should('exist');
export const clearSearchSelection = () => {
cy.get(EXCEPTIONS_TABLE_SEARCH_CLEAR).first().click();
};
export const expandExceptionActions = () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click();
};
export const exportExceptionList = () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_EXPORT_BTN).first().click();
};
export const deleteExceptionListWithoutRuleReference = () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist');
};
export const deleteExceptionListWithRuleReference = () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('exist');
cy.get(EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist');
};
export const searchForExceptionList = (searchText: string) => {
@ -28,22 +51,7 @@ export const searchForExceptionList = (searchText: string) => {
}
};
export const deleteExceptionListWithoutRuleReference = () => {
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist');
};
export const deleteExceptionListWithRuleReference = () => {
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('exist');
cy.get(EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist');
};
export const exportExceptionList = () => {
cy.get(EXCEPTIONS_TABLE_EXPORT_BTN).first().click();
};
export const clearSearchSelection = () => {
cy.get(EXCEPTIONS_TABLE_SEARCH_CLEAR).first().click();
export const waitForExceptionsTableToBeLoaded = () => {
cy.get(EXCEPTIONS_TABLE).should('exist');
cy.get(EXCEPTIONS_TABLE_SEARCH).should('exist');
};

View file

@ -54,7 +54,7 @@ export const RULES = i18n.translate('xpack.securitySolution.navigation.rules', {
});
export const EXCEPTIONS = i18n.translate('xpack.securitySolution.navigation.exceptions', {
defaultMessage: 'Exception lists',
defaultMessage: 'Rule Exceptions',
});
export const ALERTS = i18n.translate('xpack.securitySolution.navigation.alerts', {

View file

@ -304,7 +304,7 @@ describe('Navigation Breadcrumbs', () => {
expect(breadcrumbs).toEqual([
securityBreadCrumb,
{
text: 'Exception lists',
text: 'Rule Exceptions',
href: '',
},
]);
@ -623,7 +623,7 @@ describe('Navigation Breadcrumbs', () => {
securityBreadCrumb,
manageBreadcrumbs,
{
text: 'Exception lists',
text: 'Rule Exceptions',
href: '',
},
]);

View file

@ -97,7 +97,7 @@ Object {
"href": "securitySolutionUI/exceptions",
"id": "exceptions",
"isSelected": false,
"name": "Exception lists",
"name": "Rule Exceptions",
"onClick": [Function],
},
],

View file

@ -1,175 +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 from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types';
import { DEFAULT_RELATIVE_DATE_THRESHOLD } from '../../../../../common/constants';
import type { FormatUrl } from '../../../../common/components/link_to';
import { PopoverItems } from '../../../../common/components/popover_items';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import { getRuleDetailsUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
import { LinkAnchor } from '../../../../common/components/links';
import * as i18n from './translations';
import type { ExceptionListInfo } from './use_all_exception_lists';
import type { ExceptionsTableItem } from './types';
export type AllExceptionListsColumns = EuiBasicTableColumn<ExceptionsTableItem>;
const RULES_TO_DISPLAY = 1;
export const getAllExceptionListsColumns = (
onExport: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void,
onDelete: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void,
formatUrl: FormatUrl,
navigateToUrl: (url: string) => Promise<void>,
isKibanaReadOnly: boolean
): AllExceptionListsColumns[] => [
{
align: 'left',
field: 'list_id',
name: i18n.EXCEPTION_LIST_ID_TITLE,
truncateText: true,
dataType: 'string',
width: '20%',
render: (value: ExceptionListInfo['list_id']) => (
<EuiToolTip content={value} anchorClassName="eui-textTruncate">
<span data-test-subj="exceptionsTableListId">{value}</span>
</EuiToolTip>
),
},
{
align: 'left',
field: 'name',
name: i18n.EXCEPTION_LIST_NAME,
truncateText: true,
dataType: 'string',
width: '20%',
render: (value: ExceptionListInfo['name']) => (
<EuiToolTip content={value} anchorClassName="eui-textTruncate">
<span data-test-subj="exceptionsTableName">{value}</span>
</EuiToolTip>
),
},
{
field: 'rules',
name: i18n.RULES_ASSIGNED_TO_TITLE,
dataType: 'string',
width: '30%',
render: (rules: ExceptionListInfo['rules']) => {
const renderItem = <T extends ExceptionListInfo['rules'][number]>(
{ id, name }: T,
index: number,
items: T[]
) => {
const ruleHref = formatUrl(getRuleDetailsUrl(id));
const isSeparator = index !== items.length - 1;
return (
<>
<EuiToolTip content={name} anchorClassName="eui-textTruncate">
<>
<LinkAnchor
key={id}
data-test-subj="ruleNameLink"
onClick={(ev: { preventDefault: () => void }) => {
ev.preventDefault();
navigateToUrl(ruleHref);
}}
href={ruleHref}
>
{name}
{isSeparator && ','}
</LinkAnchor>
</>
</EuiToolTip>
{isSeparator && ' '}
</>
);
};
return (
<PopoverItems
items={rules}
numberOfItemsToDisplay={RULES_TO_DISPLAY}
popoverTitle={i18n.RULES_ASSIGNED_TO_TITLE}
popoverButtonTitle={i18n.showMoreRules(rules.length - 1)}
renderItem={renderItem}
dataTestPrefix="rules"
/>
);
},
},
{
align: 'left',
field: 'created_at',
name: i18n.LIST_DATE_CREATED_TITLE,
truncateText: true,
dataType: 'date',
width: '15%',
render: (value: ExceptionListInfo['created_at']) => (
<FormattedRelativePreferenceDate
relativeThresholdInHrs={DEFAULT_RELATIVE_DATE_THRESHOLD}
value={value}
tooltipFieldName={i18n.LIST_DATE_CREATED_TITLE}
tooltipAnchorClassName="eui-textTruncate"
/>
),
},
{
align: 'left',
field: 'updated_at',
name: i18n.LIST_DATE_UPDATED_TITLE,
truncateText: true,
width: '15%',
render: (value: ExceptionListInfo['updated_at']) => (
<FormattedRelativePreferenceDate
relativeThresholdInHrs={DEFAULT_RELATIVE_DATE_THRESHOLD}
value={value}
tooltipFieldName={i18n.LIST_DATE_UPDATED_TITLE}
tooltipAnchorClassName="eui-textTruncate"
/>
),
},
{
align: 'left',
width: '76px',
name: i18n.EXCEPTION_LIST_ACTIONS,
actions: [
{
render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => (
<EuiButtonIcon
onClick={onExport({
id,
listId,
namespaceType,
})}
aria-label="Export exception list"
iconType="exportAction"
data-test-subj="exceptionsTableExportButton"
/>
),
},
{
render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => {
return listId === 'endpoint_list' || isKibanaReadOnly ? (
<></>
) : (
<EuiButtonIcon
color="danger"
onClick={onDelete({ id, listId, namespaceType })}
aria-label="Delete exception list"
iconType="trash"
data-test-subj="exceptionsTableDeleteButton"
/>
);
},
},
],
},
];

View file

@ -1,13 +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 type { ExceptionListInfo } from './use_all_exception_lists';
export interface ExceptionsTableItem extends ExceptionListInfo {
isDeleting: boolean;
isExporting: boolean;
}

View file

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

View file

@ -0,0 +1,26 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/security_solution/public/exceptions'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/exceptions',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/security_solution/public/exceptions/**/*.{ts,tsx}',
],
// See: https://github.com/elastic/kibana/issues/117255, the moduleNameMapper creates mocks to avoid memory leaks from kibana core.
moduleNameMapper: {
'core/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/core.mock.ts',
'task_manager/server$':
'<rootDir>/x-pack/plugins/security_solution/server/__mocks__/task_manager.mock.ts',
'alerting/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/alert.mock.ts',
'actions/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/action.mock.ts',
},
};

View file

@ -0,0 +1,150 @@
/*
* 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

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

View file

@ -8,18 +8,18 @@
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 { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks';
import { useAllExceptionLists } from './use_all_exception_lists';
import { useHistory } from 'react-router-dom';
import { generateHistoryMock } from '../../../../common/utils/route/mocks';
import { generateHistoryMock } from '../../common/utils/route/mocks';
jest.mock('../../../../detections/components/user_info');
jest.mock('../../../../common/lib/kibana');
jest.mock('../../detections/components/user_info');
jest.mock('../../common/lib/kibana');
jest.mock('./use_all_exception_lists');
jest.mock('@kbn/securitysolution-list-hooks');
jest.mock('react-router-dom', () => {
@ -39,7 +39,7 @@ 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 }),
}));
@ -90,26 +90,25 @@ describe('ExceptionListsTable', () => {
]);
});
it('does not render delete option if list is "endpoint_list"', async () => {
it('renders delete option as disabled if list is "endpoint_list"', async () => {
const wrapper = mount(
<TestProviders>
<ExceptionListsTable />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(0).text()).toEqual(
'endpoint_list'
);
expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(1).text()).toEqual(
'not_endpoint_list'
);
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')
).toBeFalsy();
).toBeTruthy();
});
it('does not render delete option if user is read only', async () => {
it('renders delete option as disabled if user is read only', async () => {
(useUserData as jest.Mock).mockReturnValue([
{
loading: false,
@ -123,10 +122,12 @@ describe('ExceptionListsTable', () => {
<ExceptionListsTable />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(1).text()).toEqual(
'not_endpoint_list'
);
expect(wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button')).toHaveLength(0);
wrapper
.find('[data-test-subj="exceptionsListCardOverflowActions"] button')
.at(0)
.simulate('click');
expect(
wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled')
).toBeTruthy();
});
});

View file

@ -5,11 +5,18 @@
* 2.0.
*/
import React, { useMemo, useEffect, useCallback, useState } from 'react';
import type { CriteriaWithPagination, EuiSearchBarProps } from '@elastic/eui';
import React, { useEffect, useCallback, useState } from 'react';
import type { EuiSearchBarProps } from '@elastic/eui';
import {
EuiBasicTable,
EuiEmptyPrompt,
EuiButtonEmpty,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPagination,
EuiPopover,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiProgress,
EuiSpacer,
@ -20,28 +27,25 @@ 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 { useFormatUrl } from '../../../../common/components/link_to';
import { Loader } from '../../../../common/components/loader';
import { useKibana } from '../../../../common/lib/kibana';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { hasUserCRUDPermission } from '../../../../common/utils/privileges';
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';
import * as i18n from './translations_exceptions_table';
import { ExceptionsTableUtilityBar } from './exceptions_table_utility_bar';
import type { AllExceptionListsColumns } from './columns';
import { getAllExceptionListsColumns } from './columns';
import { useAllExceptionLists } from './use_all_exception_lists';
import { ReferenceErrorModal } from '../../../../detections/components/value_lists_management_flyout/reference_error_modal';
import { patchRule } from '../../../rule_management/api/api';
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 '../../../rule_management_ui/components/rules_table/helpers';
import { SecurityPageName } from '../../../../../common/constants';
import { useUserData } from '../../../../detections/components/user_info';
import { useListsConfig } from '../../../../detections/containers/detection_engine/lists/use_lists_config';
import type { ExceptionsTableItem } from './types';
import { MissingPrivilegesCallOut } from '../../../../detections/components/callouts/missing_privileges_callout';
import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../../common/endpoint/service/artifacts/constants';
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 { ImportExceptionListFlyout } from './import_exceptions_list_flyout';
export type Func = () => Promise<void>;
@ -62,15 +66,13 @@ const exceptionReferenceModalInitialState: ReferenceModalState = {
};
export const ExceptionListsTable = React.memo(() => {
const { formatUrl } = useFormatUrl(SecurityPageName.rules);
const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData();
const hasPermissions = hasUserCRUDPermission(canUserCRUD);
const { loading: listsConfigLoading } = useListsConfig();
const loading = userInfoLoading || listsConfigLoading;
const {
services: { http, notifications, timelines, application },
services: { http, notifications, timelines },
} = useKibana();
const { exportExceptionList, deleteExceptionList } = useApi(http);
@ -91,12 +93,12 @@ export const ExceptionListsTable = React.memo(() => {
const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists({
exceptionLists: exceptions ?? [],
});
const [initLoading, setInitLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState(Date.now());
const [deletingListIds, setDeletingListIds] = useState<string[]>([]);
const [exportingListIds, setExportingListIds] = useState<string[]>([]);
const [exportDownload, setExportDownload] = useState<{ name?: string; blob?: Blob }>({});
const { navigateToUrl } = application;
const [displayImportListFlyout, setDisplayImportListFlyout] = useState(false);
const { addError, addSuccess } = useAppToasts();
const handleDeleteSuccess = useCallback(
@ -121,11 +123,6 @@ export const ExceptionListsTable = React.memo(() => {
({ id, listId, namespaceType }: { id: string; listId: string; namespaceType: NamespaceType }) =>
async () => {
try {
setDeletingListIds((ids) => [...ids, id]);
if (refreshExceptions != null) {
refreshExceptions();
}
if (exceptionsListsRef[id] != null && exceptionsListsRef[id].rules.length === 0) {
await deleteExceptionList({
id,
@ -150,8 +147,6 @@ export const ExceptionListsTable = React.memo(() => {
// route to patch rules with associated exception list
} catch (error) {
handleDeleteError(error);
} finally {
setDeletingListIds((ids) => ids.filter((_id) => _id !== id));
}
},
[
@ -182,7 +177,6 @@ export const ExceptionListsTable = React.memo(() => {
const handleExport = useCallback(
({ id, listId, namespaceType }: { id: string; listId: string; namespaceType: NamespaceType }) =>
async () => {
setExportingListIds((ids) => [...ids, id]);
await exportExceptionList({
id,
listId,
@ -194,18 +188,6 @@ export const ExceptionListsTable = React.memo(() => {
[exportExceptionList, handleExportError, handleExportSuccess]
);
const exceptionsColumns = useMemo((): AllExceptionListsColumns[] => {
// Defaulting to true to default to the lower privilege first
const isKibanaReadOnly = (canUserREAD && !canUserCRUD) ?? true;
return getAllExceptionListsColumns(
handleExport,
handleDelete,
formatUrl,
navigateToUrl,
isKibanaReadOnly
);
}, [handleExport, handleDelete, formatUrl, navigateToUrl, canUserREAD, canUserCRUD]);
const handleRefresh = useCallback((): void => {
if (refreshExceptions != null) {
setLastUpdated(Date.now());
@ -219,16 +201,6 @@ export const ExceptionListsTable = React.memo(() => {
}
}, [initLoading, loading, loadingExceptions, loadingTableInfo]);
const emptyPrompt = useMemo((): JSX.Element => {
return (
<EuiEmptyPrompt
title={<h3>{i18n.NO_EXCEPTION_LISTS}</h3>}
titleSize="xs"
body={i18n.NO_LISTS_BODY}
/>
);
}, []);
const handleSearch = useCallback(
async ({
query,
@ -253,7 +225,6 @@ export const ExceptionListsTable = React.memo(() => {
);
const handleCloseReferenceErrorModal = useCallback((): void => {
setDeletingListIds([]);
setShowReferenceErrorModal(false);
setReferenceModalState({
contentText: '',
@ -297,7 +268,6 @@ export const ExceptionListsTable = React.memo(() => {
handleDeleteError(err);
} finally {
setReferenceModalState(exceptionReferenceModalInitialState);
setDeletingListIds([]);
setShowReferenceErrorModal(false);
if (refreshExceptions != null) {
refreshExceptions();
@ -313,51 +283,108 @@ export const ExceptionListsTable = React.memo(() => {
refreshExceptions,
]);
const paginationMemo = useMemo(
() => ({
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
totalItemCount: pagination.total || 0,
pageSizeOptions: [5, 10, 20, 50, 100, 200, 300],
}),
[pagination]
);
const handleOnDownload = useCallback(() => {
setExportDownload({});
}, []);
const tableItems = useMemo<ExceptionsTableItem[]>(
() =>
(exceptionListsWithRuleRefs ?? []).map((item) => ({
...item,
isDeleting: deletingListIds.includes(item.id),
isExporting: exportingListIds.includes(item.id),
})),
[deletingListIds, exceptionListsWithRuleRefs, exportingListIds]
const [activePage, setActivePage] = useState(0);
const [rowSize, setRowSize] = useState(5);
const [isRowSizePopoverOpen, setIsRowSizePopoverOpen] = useState(false);
const onRowSizeButtonClick = () => setIsRowSizePopoverOpen((val) => !val);
const closeRowSizePopover = () => setIsRowSizePopoverOpen(false);
const rowSizeButton = (
<EuiButtonEmpty
size="xs"
color="text"
iconType="arrowDown"
iconSide="right"
onClick={onRowSizeButtonClick}
>
{`Rows per page: ${rowSize}`}
</EuiButtonEmpty>
);
const handlePaginationChange = useCallback(
(criteria: CriteriaWithPagination<ExceptionsTableItem>) => {
const { index, size } = criteria.page;
setPagination((currentPagination) => ({
...currentPagination,
perPage: size,
page: index + 1,
}));
},
[setPagination]
);
const getIconType = (size: number) => {
return size === rowSize ? 'check' : 'empty';
};
const rowSizeItems = [
<EuiContextMenuItem
key="5 rows"
icon={getIconType(5)}
onClick={() => {
closeRowSizePopover();
setRowSize(5);
}}
>
{'5 rows'}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="10 rows"
icon={getIconType(10)}
onClick={() => {
closeRowSizePopover();
setRowSize(10);
}}
>
{'10 rows'}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="25 rows"
icon={getIconType(25)}
onClick={() => {
closeRowSizePopover();
setRowSize(25);
}}
>
{'25 rows'}
</EuiContextMenuItem>,
];
useEffect(() => {
setPagination({
// off-by-one error
// we should really update the api to be zero-index based
// the same way the pagination component in EUI is zero based.
page: activePage + 1,
perPage: rowSize,
total: 0,
});
}, [activePage, rowSize, setPagination]);
const goToPage = (pageNumber: number) => setActivePage(pageNumber);
return (
<>
<MissingPrivilegesCallOut />
<EuiPageHeader
pageTitle={i18n.ALL_EXCEPTIONS}
description={
<p>{timelines.getLastUpdated({ showUpdating: loading, updatedAt: lastUpdated })}</p>
}
/>
<EuiFlexGroup>
<EuiFlexItem>
<EuiPageHeader
pageTitle={i18n.ALL_EXCEPTIONS}
description={timelines.getLastUpdated({
showUpdating: loading,
updatedAt: lastUpdated,
})}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton iconType={'importAction'} onClick={() => setDisplayImportListFlyout(true)}>
{i18n.IMPORT_EXCEPTION_LIST}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{displayImportListFlyout && (
<ImportExceptionListFlyout
handleRefresh={handleRefresh}
http={http}
addSuccess={addSuccess}
addError={addError}
setDisplayImportListFlyout={setDisplayImportListFlyout}
/>
)}
<EuiHorizontalRule />
<div data-test-subj="allExceptionListsPanel">
{loadingTableInfo && (
@ -375,7 +402,7 @@ export const ExceptionListsTable = React.memo(() => {
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{initLoading ? (
{initLoading || loadingTableInfo ? (
<EuiLoadingContent data-test-subj="initialLoadingPanelAllRulesTable" lines={10} />
) : (
<>
@ -383,18 +410,51 @@ export const ExceptionListsTable = React.memo(() => {
totalExceptionLists={exceptionListsWithRuleRefs.length}
onRefresh={handleRefresh}
/>
<EuiBasicTable<ExceptionsTableItem>
data-test-subj="exceptions-table"
columns={exceptionsColumns}
isSelectable={hasPermissions}
itemId="id"
items={tableItems}
noItemsMessage={emptyPrompt}
onChange={handlePaginationChange}
pagination={paginationMemo}
/>
<EuiSpacer size="m" />
{exceptionListsWithRuleRefs.length > 0 && canUserCRUD !== null && canUserREAD !== null && (
<React.Fragment data-test-subj="exceptionsTable">
{exceptionListsWithRuleRefs.map((excList) => (
<ExceptionsListCard
key={excList.list_id}
data-test-subj="exceptionsListCard"
readOnly={canUserREAD && !canUserCRUD}
http={http}
exceptionsList={excList}
handleDelete={handleDelete}
handleExport={handleExport}
/>
))}
</React.Fragment>
)}
</>
)}
<EuiFlexGroup>
<EuiFlexItem style={{ flex: '1 1 auto' }}>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem>
<EuiPopover
button={rowSizeButton}
isOpen={isRowSizePopoverOpen}
closePopover={closeRowSizePopover}
>
<EuiContextMenuPanel items={rowSizeItems} />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem style={{ alignItems: 'flex-end' }}>
<EuiFlexGroup alignItems="flexEnd">
<EuiFlexItem>
<EuiPagination
aria-label={'Custom pagination example'}
pageCount={pagination.total ? Math.ceil(pagination.total / rowSize) : 0}
activePage={activePage}
onPageClick={goToPage}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<AutoDownload
blob={exportDownload.blob}

View file

@ -6,7 +6,7 @@
*/
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';

View file

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

View file

@ -0,0 +1,201 @@
/*
* 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 { SetStateAction, Dispatch } from 'react';
import React, { useEffect, useRef, useCallback, useState } from 'react';
import {
useGeneratedHtmlId,
EuiButton,
EuiButtonEmpty,
EuiCheckbox,
EuiFilePicker,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiSpacer,
EuiText,
EuiTitle,
EuiFlyoutFooter,
EuiTextColor,
EuiFlyout,
} from '@elastic/eui';
import type {
BulkErrorSchema,
ImportExceptionsResponseSchema,
} from '@kbn/securitysolution-io-ts-list-types';
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 * as i18n from './translations';
export const ImportExceptionListFlyout = React.memo(
({
handleRefresh,
http,
addSuccess,
addError,
setDisplayImportListFlyout,
}: {
handleRefresh: () => void;
http: HttpSetup;
addSuccess: (toastOrTitle: ToastInput, options?: unknown) => Toast;
addError: (error: unknown, options: ErrorToastOptions) => Toast;
setDisplayImportListFlyout: Dispatch<SetStateAction<boolean>>;
}) => {
const filePickerRef = useRef<EuiFilePicker | null>(null);
const filePickerId = useGeneratedHtmlId({ prefix: 'filePicker' });
const [file, setFile] = useState<File | null>(null);
const [overwrite, setOverwrite] = useState(false);
const [asNewList, setAsNewList] = useState(false);
const resetForm = useCallback(() => {
if (filePickerRef.current?.fileInput) {
filePickerRef.current.fileInput.value = '';
filePickerRef.current.handleChange();
}
setFile(null);
setAlreadyExistingItem(false);
setAsNewList(false);
setOverwrite(false);
}, []);
const { start: importExceptionList, ...importExceptionListState } = useImportExceptionList();
const ctrl = useRef(new AbortController());
const handleImportExceptionList = useCallback(() => {
if (!importExceptionListState.loading && file) {
ctrl.current = new AbortController();
importExceptionList({
file,
http,
signal: ctrl.current.signal,
overwrite,
overwriteExceptions: overwrite,
asNewList,
});
}
}, [asNewList, file, http, importExceptionList, importExceptionListState.loading, overwrite]);
const handleImportSuccess = useCallback(
(response: ImportExceptionsResponseSchema) => {
resetForm();
addSuccess({
text: i18n.uploadSuccessMessage(file?.name ?? ''),
title: i18n.UPLOAD_SUCCESS_TITLE,
});
handleRefresh();
},
// looking for file.name but we don't wan't to render success every time file name changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[resetForm, addSuccess, handleRefresh]
);
const handleImportError = useCallback(
(errors: BulkErrorSchema[]) => {
errors.forEach((error) => {
if (!error.error.message.includes('AbortError')) {
addError(error.error.message, { title: i18n.UPLOAD_ERROR });
}
});
},
[addError]
);
const [alreadyExistingItem, setAlreadyExistingItem] = useState(false);
useEffect(() => {
if (!importExceptionListState.loading) {
if (importExceptionListState?.result?.success) {
handleImportSuccess(importExceptionListState?.result);
} else if (importExceptionListState?.result?.errors) {
handleImportError(importExceptionListState?.result?.errors);
}
}
}, [
handleImportError,
handleImportSuccess,
importExceptionListState.error,
importExceptionListState.loading,
importExceptionListState.result,
setAlreadyExistingItem,
]);
const handleFileChange = useCallback((files: FileList | null) => {
setFile(files?.item(0) ?? null);
}, []);
return (
<EuiFlyout ownFocus size="s" onClose={() => setDisplayImportListFlyout(false)}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{i18n.IMPORT_EXCEPTION_LIST_HEADER}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>{i18n.IMPORT_EXCEPTION_LIST_BODY}</EuiText>
<EuiFilePicker
id={filePickerId}
multiple
ref={filePickerRef}
initialPromptText={i18n.IMPORT_PROMPT}
onChange={handleFileChange}
display={'large'}
aria-label="Use aria labels when no actual label is in use"
/>
{alreadyExistingItem && (
<>
<EuiSpacer />
<EuiTextColor color={'danger'}>{i18n.IMPORT_EXCEPTION_LIST_WARNING}</EuiTextColor>
<EuiSpacer />
<EuiCheckbox
id={'basicCheckboxId'}
label={i18n.IMPORT_EXCEPTION_LIST_OVERWRITE}
checked={overwrite}
onChange={(e) => {
setOverwrite(!overwrite);
setAsNewList(false);
}}
/>
<EuiCheckbox
id={'createNewListCheckbox'}
label={i18n.IMPORT_EXCEPTION_LIST_AS_NEW_LIST}
checked={asNewList}
onChange={(e) => {
setAsNewList(!asNewList);
setOverwrite(false);
}}
/>
</>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={() => setDisplayImportListFlyout(false)}
flush="left"
>
{i18n.CLOSE_FLYOUT}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="exception-lists-form-import-action"
onClick={handleImportExceptionList}
disabled={file == null || importExceptionListState.loading}
>
{i18n.UPLOAD_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}
);

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, { memo } from 'react';
import styled from 'styled-components';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
interface TitleBadgeProps {
title: string;
badgeString: string;
}
const StyledFlexItem = styled(EuiFlexItem)`
border-right: 1px solid #d3dae6;
padding: 4px 12px 4px 0;
`;
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>
);
});
TitleBadge.displayName = 'TitleBadge';

View file

@ -0,0 +1,106 @@
/*
* 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.importExceptionListFlyoutHeader',
{
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',
}
);

View file

@ -88,7 +88,7 @@ export const EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER = i18n.translate(
export const ALL_EXCEPTIONS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle',
{
defaultMessage: 'Exception lists',
defaultMessage: 'Rule Exceptions',
}
);
@ -167,3 +167,37 @@ export const REFRESH_EXCEPTIONS_TABLE = i18n.translate(
defaultMessage: 'Refresh',
}
);
export const UPLOAD_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.exceptionListsImportButton',
{
defaultMessage: 'Import list',
}
);
export const uploadSuccessMessage = (fileName: string) =>
i18n.translate('xpack.securitySolution.lists.exceptionListImportSuccess', {
defaultMessage: "Exception list '{fileName}' was imported",
values: { fileName },
});
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 IMPORT_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.lists.importExceptionListButton',
{
defaultMessage: 'Import exception 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 '../../../rule_management/logic';
import { fetchRules } from '../../../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

@ -0,0 +1,48 @@
/*
* 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 { EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants';
import type { ImportExceptionsResponseSchema } from '@kbn/securitysolution-io-ts-list-types';
import type { HttpStart } from '@kbn/core/public';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
export const importExceptionList = async ({
file,
http,
signal,
overwrite,
overwriteExceptions,
asNewList,
}: {
// TODO: Replace these with kbn packaged versions once we have those available to us
// These originally came from this location below before moving them to this hacked "any" types:
// import { HttpStart, NotificationsStart } from '../../../../../src/core/public';
http: HttpStart;
signal: AbortSignal;
file: File;
overwrite: boolean;
overwriteExceptions: boolean;
asNewList: boolean;
}): Promise<ImportExceptionsResponseSchema> => {
const formData = new FormData();
formData.append('file', file as Blob);
const res = await http.post<ImportExceptionsResponseSchema>(`${EXCEPTION_LIST_URL}/_import`, {
body: formData,
query: { overwrite, overwrite_exceptions: overwriteExceptions, as_new_list: asNewList },
headers: { 'Content-Type': undefined },
method: 'POST',
signal,
});
return res;
};
const importListWithOptionalSignal = withOptionalSignal(importExceptionList);
export const useImportExceptionList = () => useAsync(importListWithOptionalSignal);

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 '../detection_engine/rule_exceptions_ui/pages/exceptions/exceptions_table';
import { ExceptionListsTable } from './manage_exceptions/exceptions_table';
import { SpyRoute } from '../common/utils/route/spy_routes';
import { NotFoundPage } from '../app/404';
import { useReadonlyHeader } from '../use_readonly_header';