mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
2e23aa0c31
commit
c1070e63a1
36 changed files with 922 additions and 398 deletions
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -299,6 +299,7 @@ describe('exception_list_client', () => {
|
|||
(): ReturnType<ExceptionListClient['importExceptionListAndItems']> => {
|
||||
return exceptionListClient.importExceptionListAndItems({
|
||||
exceptionsToImport: toReadable([getExceptionListItemSchemaMock()]),
|
||||
generateNewListId: false,
|
||||
maxExceptionsImportSize: 10_000,
|
||||
overwrite: true,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"]';
|
||||
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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: '',
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -97,7 +97,7 @@ Object {
|
|||
"href": "securitySolutionUI/exceptions",
|
||||
"id": "exceptions",
|
||||
"isSelected": false,
|
||||
"name": "Exception lists",
|
||||
"name": "Rule Exceptions",
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
|
@ -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;
|
||||
}
|
|
@ -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', () => {
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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';
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
|
@ -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';
|
||||
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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[];
|
||||
}
|
|
@ -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);
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue