[Security Solution][Detections] - Fix export on exceptions list view (#86135)

## Summary

This PR addresses a fix on the exceptions list table export functionality. A dedicated route for exception list export needed to be created. List is exported into an `.ndjson` format. 

Exception lists consist of two elements - the list itself, and its items. The export file should now contain both these elements, the list followed by its items.
This commit is contained in:
Yara Tercero 2020-12-23 00:27:37 -05:00 committed by GitHub
parent 35b10b5354
commit 3dfb1aba2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 459 additions and 23 deletions

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ID, LIST_ID, NAMESPACE_TYPE } from '../../constants.mock';
import { ExportExceptionListQuerySchema } from './export_exception_list_query_schema';
export const getExportExceptionListQuerySchemaMock = (): ExportExceptionListQuerySchema => ({
id: ID,
list_id: LIST_ID,
namespace_type: NAMESPACE_TYPE,
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { left } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { exactCheck, foldLeftRight, getPaths } from '../../shared_imports';
import {
ExportExceptionListQuerySchema,
exportExceptionListQuerySchema,
} from './export_exception_list_query_schema';
import { getExportExceptionListQuerySchemaMock } from './export_exception_list_query_schema.mock';
describe('export_exception_list_schema', () => {
test('it should validate a typical lists request', () => {
const payload = getExportExceptionListQuerySchemaMock();
const decoded = exportExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT accept an undefined for an id', () => {
const payload = getExportExceptionListQuerySchemaMock();
// @ts-expect-error
delete payload.id;
const decoded = exportExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']);
expect(message.schema).toEqual({});
});
test('it should default namespace_type to "single" if an undefined given for namespacetype', () => {
const payload = getExportExceptionListQuerySchemaMock();
delete payload.namespace_type;
const decoded = exportExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(message.schema).toEqual({
id: 'uuid_here',
list_id: 'some-list-id',
namespace_type: 'single',
});
});
test('it should NOT accept an undefined for an list_id', () => {
const payload = getExportExceptionListQuerySchemaMock();
// @ts-expect-error
delete payload.list_id;
const decoded = exportExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "list_id"',
]);
expect(message.schema).toEqual({});
});
test('it should not allow an extra key to be sent in', () => {
const payload: ExportExceptionListQuerySchema & {
extraKey?: string;
} = getExportExceptionListQuerySchemaMock();
payload.extraKey = 'some new value';
const decoded = exportExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']);
expect(message.schema).toEqual({});
});
});

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
import { id, list_id, namespace_type } from '../common/schemas';
export const exportExceptionListQuerySchema = t.exact(
t.type({
id,
list_id,
namespace_type,
// TODO: Add file_name here with a default value
})
);
export type ExportExceptionListQuerySchema = t.OutputOf<typeof exportExceptionListQuerySchema>;

View file

@ -14,6 +14,7 @@ export * from './delete_exception_list_item_schema';
export * from './delete_exception_list_schema';
export * from './delete_list_item_schema';
export * from './delete_list_schema';
export * from './export_exception_list_query_schema';
export * from './export_list_item_query_schema';
export * from './find_endpoint_list_item_schema';
export * from './find_exception_list_item_schema';

View file

@ -25,6 +25,7 @@ import {
addExceptionListItem,
deleteExceptionListById,
deleteExceptionListItemById,
exportExceptionList,
fetchExceptionListById,
fetchExceptionListItemById,
fetchExceptionLists,
@ -870,4 +871,50 @@ describe('Exceptions Lists API', () => {
expect(exceptionResponse).toEqual({});
});
});
describe('#exportExceptionList', () => {
const blob: Blob = {
arrayBuffer: jest.fn(),
size: 89,
slice: jest.fn(),
stream: jest.fn(),
text: jest.fn(),
type: 'json',
} as Blob;
beforeEach(() => {
httpMock.fetch.mockResolvedValue(blob);
});
test('it invokes "exportExceptionList" with expected url and body values', async () => {
await exportExceptionList({
http: httpMock,
id: 'some-id',
listId: 'list-id',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/_export', {
method: 'GET',
query: {
id: 'some-id',
list_id: 'list-id',
namespace_type: 'single',
},
signal: abortCtrl.signal,
});
});
test('it returns expected list to export on success', async () => {
const exceptionResponse = await exportExceptionList({
http: httpMock,
id: 'some-id',
listId: 'list-id',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual(blob);
});
});
});

View file

@ -41,6 +41,7 @@ import {
ApiCallByIdProps,
ApiCallByListIdProps,
ApiCallFetchExceptionListsProps,
ExportExceptionListProps,
UpdateExceptionListItemProps,
UpdateExceptionListProps,
} from './types';
@ -537,3 +538,27 @@ export const addEndpointExceptionList = async ({
return Promise.reject(error);
}
};
/**
* Fetch an ExceptionList by providing a ExceptionList ID
*
* @param http Kibana http service
* @param id ExceptionList ID (not list_id)
* @param listId ExceptionList LIST_ID (not id)
* @param namespaceType ExceptionList namespace_type
* @param signal to cancel request
*
* @throws An error if response is not OK
*/
export const exportExceptionList = async ({
http,
id,
listId,
namespaceType,
signal,
}: ExportExceptionListProps): Promise<Blob> =>
http.fetch<Blob>(`${EXCEPTION_LIST_URL}/_export`, {
method: 'GET',
query: { id, list_id: listId, namespace_type: namespaceType },
signal,
});

View file

@ -9,7 +9,7 @@ import { useMemo } from 'react';
import * as Api from '../api';
import { HttpStart } from '../../../../../../src/core/public';
import { ExceptionListItemSchema, ExceptionListSchema } from '../../../common/schemas';
import { ApiCallFindListsItemsMemoProps, ApiCallMemoProps } from '../types';
import { ApiCallFindListsItemsMemoProps, ApiCallMemoProps, ApiListExportProps } from '../types';
import { getIdsAndNamespaces } from '../utils';
export interface ExceptionsApi {
@ -22,6 +22,7 @@ export interface ExceptionsApi {
arg: ApiCallMemoProps & { onSuccess: (arg: ExceptionListSchema) => void }
) => Promise<void>;
getExceptionListsItems: (arg: ApiCallFindListsItemsMemoProps) => Promise<void>;
exportExceptionList: (arg: ApiListExportProps) => Promise<void>;
}
export const useApi = (http: HttpStart): ExceptionsApi => {
@ -67,6 +68,28 @@ export const useApi = (http: HttpStart): ExceptionsApi => {
onError(error);
}
},
async exportExceptionList({
id,
listId,
namespaceType,
onError,
onSuccess,
}: ApiListExportProps): Promise<void> {
const abortCtrl = new AbortController();
try {
const blob = await Api.exportExceptionList({
http,
id,
listId,
namespaceType,
signal: abortCtrl.signal,
});
onSuccess(blob);
} catch (error) {
onError(error);
}
},
async getExceptionItem({
id,
namespaceType,

View file

@ -90,6 +90,17 @@ export interface ApiCallMemoProps {
onSuccess: () => void;
}
// TODO: Switch to use ApiCallMemoProps
// after cleaning up exceptions/api file to
// remove unnecessary validation checks
export interface ApiListExportProps {
id: string;
listId: string;
namespaceType: NamespaceType;
onError: (err: Error) => void;
onSuccess: (blob: Blob) => void;
}
export interface ApiCallFindListsItemsMemoProps {
lists: ExceptionListIdentifiers[];
filterOptions: FilterExceptionsOptions[];
@ -156,3 +167,11 @@ export interface AddEndpointExceptionListProps {
http: HttpStart;
signal: AbortSignal;
}
export interface ExportExceptionListProps {
http: HttpStart;
id: string;
listId: string;
namespaceType: NamespaceType;
signal: AbortSignal;
}

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IRouter } from 'kibana/server';
import { EXCEPTION_LIST_URL } from '../../common/constants';
import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps';
import { exportExceptionListQuerySchema } from '../../common/schemas';
import { getExceptionListClient } from './utils';
export const exportExceptionListRoute = (router: IRouter): void => {
router.get(
{
options: {
tags: ['access:lists-read'],
},
path: `${EXCEPTION_LIST_URL}/_export`,
validate: {
query: buildRouteValidation(exportExceptionListQuerySchema),
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
const { id, list_id: listId, namespace_type: namespaceType } = request.query;
const exceptionLists = getExceptionListClient(context);
const exceptionList = await exceptionLists.getExceptionList({
id,
listId,
namespaceType,
});
if (exceptionList == null) {
return siemResponse.error({
body: `list_id: ${listId} does not exist`,
statusCode: 400,
});
} else {
const { exportData: exportList } = getExport([exceptionList]);
const listItems = await exceptionLists.findExceptionListItem({
filter: undefined,
listId,
namespaceType,
page: 1,
perPage: 10000,
sortField: 'exception-list.created_at',
sortOrder: 'desc',
});
const { exportData: exportListItems, exportDetails } = getExport(listItems?.data ?? []);
const responseBody = [
exportList,
exportListItems,
{ exception_list_items_details: exportDetails },
];
// TODO: Allow the API to override the name of the file to export
const fileName = exceptionList.list_id;
return response.ok({
body: transformDataToNdjson(responseBody),
headers: {
'Content-Disposition': `attachment; filename="${fileName}"`,
'Content-Type': 'application/ndjson',
},
});
}
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};
const transformDataToNdjson = (data: unknown[]): string => {
if (data.length !== 0) {
const dataString = data.map((dataItem) => JSON.stringify(dataItem)).join('\n');
return `${dataString}\n`;
} else {
return '';
}
};
export const getExport = (
data: unknown[]
): {
exportData: string;
exportDetails: string;
} => {
const ndjson = transformDataToNdjson(data);
const exportDetails = JSON.stringify({
exported_count: data.length,
});
return { exportData: ndjson, exportDetails: `${exportDetails}\n` };
};

View file

@ -17,6 +17,7 @@ export * from './delete_exception_list_item_route';
export * from './delete_list_index_route';
export * from './delete_list_item_route';
export * from './delete_list_route';
export * from './export_exception_list_route';
export * from './export_list_item_route';
export * from './find_endpoint_list_item_route';
export * from './find_exception_list_item_route';

View file

@ -22,6 +22,7 @@ import {
deleteListIndexRoute,
deleteListItemRoute,
deleteListRoute,
exportExceptionListRoute,
exportListItemRoute,
findEndpointListItemRoute,
findExceptionListItemRoute,
@ -76,6 +77,7 @@ export const initRoutes = (router: IRouter, config: ConfigType): void => {
updateExceptionListRoute(router);
deleteExceptionListRoute(router);
findExceptionListRoute(router);
exportExceptionListRoute(router);
// exception list items
createExceptionListItemRoute(router);

View file

@ -32,8 +32,8 @@ import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import * as i18n from './translations';
import { buildColumns } from './table_helpers';
import { ValueListsForm } from './form';
import { AutoDownload } from './auto_download';
import { ReferenceErrorModal } from './reference_error_modal';
import { AutoDownload } from '../../../common/components/auto_download/auto_download';
interface ValueListsModalProps {
onClose: () => void;

View file

@ -9,6 +9,7 @@ import React from 'react';
import { EuiButtonIcon, EuiBasicTableColumn, EuiToolTip } from '@elastic/eui';
import { History } from 'history';
import { NamespaceType } from '../../../../../../../../lists/common';
import { FormatUrl } from '../../../../../../common/components/link_to';
import { LinkAnchor } from '../../../../../../common/components/links';
import * as i18n from './translations';
@ -16,7 +17,11 @@ import { ExceptionListInfo } from './use_all_exception_lists';
import { getRuleDetailsUrl } from '../../../../../../common/components/link_to/redirect_to_detection_engine';
export type AllExceptionListsColumns = EuiBasicTableColumn<ExceptionListInfo>;
export type Func = (listId: string) => () => void;
export type Func = (arg: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => () => void;
export const getAllExceptionListsColumns = (
onExport: Func,
@ -96,9 +101,13 @@ export const getAllExceptionListsColumns = (
align: 'center',
isExpander: false,
width: '25px',
render: (list: ExceptionListInfo) => (
render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => (
<EuiButtonIcon
onClick={onExport(list.id)}
onClick={onExport({
id,
listId,
namespaceType,
})}
aria-label="Export exception list"
iconType="exportAction"
/>
@ -108,10 +117,14 @@ export const getAllExceptionListsColumns = (
align: 'center',
width: '25px',
isExpander: false,
render: (list: ExceptionListInfo) => (
render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => (
<EuiButtonIcon
color="danger"
onClick={onDelete(list.id)}
onClick={onDelete({
id,
listId,
namespaceType,
})}
aria-label="Delete exception list"
iconType="trash"
/>

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useEffect, useCallback, useState, ChangeEvent } from 'react';
import React, { useMemo, useEffect, useCallback, useState } from 'react';
import {
EuiBasicTable,
EuiEmptyPrompt,
@ -16,8 +16,10 @@ import styled from 'styled-components';
import { History } from 'history';
import { set } from 'lodash/fp';
import { AutoDownload } from '../../../../../../common/components/auto_download/auto_download';
import { NamespaceType } from '../../../../../../../../lists/common';
import { useKibana } from '../../../../../../common/lib/kibana';
import { useExceptionLists } from '../../../../../../shared_imports';
import { useApi, useExceptionLists } from '../../../../../../shared_imports';
import { FormatUrl } from '../../../../../../common/components/link_to';
import { HeaderSection } from '../../../../../../common/components/header_section';
import { Loader } from '../../../../../../common/components/loader';
@ -51,6 +53,7 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
const {
services: { http, notifications },
} = useKibana();
const { exportExceptionList } = useApi(http);
const [filters, setFilters] = useState<ExceptionListFilter>({
name: null,
list_id: null,
@ -69,10 +72,67 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
});
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 handleDelete = useCallback((id: string) => () => {}, []);
const handleDelete = useCallback(
({
id,
listId,
namespaceType,
}: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => async () => {
try {
setDeletingListIds((ids) => [...ids, id]);
// route to patch rules with associated exception list
} catch (error) {
notifications.toasts.addError(error, { title: i18n.EXCEPTION_DELETE_ERROR });
} finally {
setDeletingListIds((ids) => [...ids.filter((_id) => _id !== id)]);
}
},
[notifications.toasts]
);
const handleExport = useCallback((id: string) => () => {}, []);
const handleExportSuccess = useCallback(
(listId: string) => (blob: Blob): void => {
setExportDownload({ name: listId, blob });
},
[]
);
const handleExportError = useCallback(
(err: Error) => {
notifications.toasts.addError(err, { title: i18n.EXCEPTION_EXPORT_ERROR });
},
[notifications.toasts]
);
const handleExport = useCallback(
({
id,
listId,
namespaceType,
}: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => async () => {
setExportingListIds((ids) => [...ids, id]);
await exportExceptionList({
id,
listId,
namespaceType,
onError: handleExportError,
onSuccess: handleExportSuccess(listId),
});
},
[exportExceptionList, handleExportError, handleExportSuccess]
);
const exceptionsColumns = useMemo((): AllExceptionListsColumns[] => {
return getAllExceptionListsColumns(handleExport, handleDelete, history, formatUrl);
@ -122,14 +182,6 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
setFilters(formattedFilter);
}, []);
const handleSearchChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const val = event.target.value;
handleSearch(val);
},
[handleSearch]
);
const paginationMemo = useMemo(
() => ({
pageIndex: pagination.page - 1,
@ -140,8 +192,23 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
[pagination]
);
const handleOnDownload = useCallback(() => {
setExportDownload({});
}, []);
const tableItems = (data ?? []).map((item) => ({
...item,
isDeleting: deletingListIds.includes(item.id),
isExporting: exportingListIds.includes(item.id),
}));
return (
<>
<AutoDownload
blob={exportDownload.blob}
name={`${exportDownload.name}.ndjson`}
onDownload={handleOnDownload}
/>
<Panel loading={!initLoading && loadingTableInfo} data-test-subj="allExceptionListsPanel">
<>
{loadingTableInfo && (
@ -162,7 +229,6 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
aria-label={i18n.EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER}
placeholder={i18n.EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER}
onSearch={handleSearch}
onChange={handleSearchChange}
disabled={initLoading}
incremental={false}
fullWidth
@ -188,7 +254,7 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
columns={exceptionsColumns}
isSelectable={!hasNoPermissions ?? false}
itemId="id"
items={data ?? []}
items={tableItems}
noItemsMessage={emptyPrompt}
onChange={() => {}}
pagination={paginationMemo}

View file

@ -35,7 +35,7 @@ export const LIST_DATE_CREATED_TITLE = i18n.translate(
);
export const LIST_DATE_UPDATED_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.dateUPdatedTitle',
'xpack.securitySolution.detectionEngine.rules.all.exceptions.dateUpdatedTitle',
{
defaultMessage: 'Last edited',
}
@ -75,3 +75,24 @@ export const NO_LISTS_BODY = i18n.translate(
defaultMessage: "We weren't able to find any exception lists.",
}
);
export const EXCEPTION_EXPORT_SUCCESS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.exportSuccess',
{
defaultMessage: 'Exception list export success',
}
);
export const EXCEPTION_EXPORT_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.exportError',
{
defaultMessage: 'Exception list export error',
}
);
export const EXCEPTION_DELETE_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.deleteError',
{
defaultMessage: 'Error occurred deleting exception list',
}
);

View file

@ -61,7 +61,7 @@ export const useAllExceptionLists = ({
const { data: rules } = await fetchRules({
pagination: {
page: 1,
perPage: 500,
perPage: 10000,
total: 0,
},
signal: abortCtrl.signal,