From 569b1f66064144763e6cf1e5fa2cb46eeecd5071 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Tue, 14 Jan 2020 09:25:07 -0700 Subject: [PATCH] [SIEM] Use import/export API instead of client implementation (#54680) ## Summary This PR switches the Rule Import / Export functionality away from the client-side implementation (that was leveraging the create/read Rule API) to the new explicit `/rules/_import` & `/rules/_export` API introduced in https://github.com/elastic/kibana/pull/54332. Note: This PR also disables the ability to export `immutable` rules. ![image](https://user-images.githubusercontent.com/2946766/72311962-c0963680-3643-11ea-812f-237bc51be7dc.png) Sample error message: Screen Shot 2020-01-13 at 20 22 45 ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. - [X] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~ - [X] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios - [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~ ### For maintainers - [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ - [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ --- .../siem/public/components/toasters/index.tsx | 28 ++++- .../containers/detection_engine/rules/api.ts | 79 +++++++++++++ .../detection_engine/rules/types.ts | 27 +++++ .../rules/all/batch_actions.tsx | 2 +- .../detection_engine/rules/all/columns.tsx | 1 + .../detection_engine/rules/all/index.tsx | 6 +- .../detection_engine/rules/all/reducer.ts | 6 +- .../__snapshots__/index.test.tsx.snap | 7 +- .../components/import_rule_modal/index.tsx | 105 ++++++++---------- .../import_rule_modal/translations.ts | 13 ++- .../__snapshots__/index.test.tsx.snap | 3 - .../components/json_downloader/index.test.tsx | 60 ---------- .../components/json_downloader/index.tsx | 75 ------------- .../__snapshots__/index.test.tsx.snap | 3 + .../components/rule_downloader/index.test.tsx | 18 +++ .../components/rule_downloader/index.tsx | 89 +++++++++++++++ .../rule_downloader/translations.ts | 14 +++ .../detection_engine/rules/translations.ts | 2 +- 18 files changed, 325 insertions(+), 213 deletions(-) delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx index b046c91dcd912..6d13bbd778f53 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiGlobalToastList, EuiGlobalToastListToast as Toast, EuiButton } from '@elastic/eui'; +import { EuiButton, EuiGlobalToastList, EuiGlobalToastListToast as Toast } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { createContext, Dispatch, useReducer, useContext, useState } from 'react'; +import React, { createContext, Dispatch, useContext, useReducer, useState } from 'react'; import styled from 'styled-components'; import uuid from 'uuid'; @@ -143,7 +143,7 @@ export const displayErrorToast = ( errorTitle: string, errorMessages: string[], dispatchToaster: React.Dispatch -) => { +): void => { const toast: AppToast = { id: uuid.v4(), title: errorTitle, @@ -156,3 +156,25 @@ export const displayErrorToast = ( toast, }); }; + +/** + * Displays a success toast for the provided title and message + * + * @param title success message to display in toaster and modal + * @param dispatchToaster provided by useStateToaster() + */ +export const displaySuccessToast = ( + title: string, + dispatchToaster: React.Dispatch +): void => { + const toast: AppToast = { + id: uuid.v4(), + title, + color: 'success', + iconType: 'check', + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index 8f8b66ae35a3b..a13d6b75af630 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -16,13 +16,17 @@ import { Rule, FetchRuleProps, BasicFetchProps, + ImportRulesProps, + ExportRulesProps, RuleError, + ImportRulesResponse, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; import { DETECTION_ENGINE_RULES_URL, DETECTION_ENGINE_PREPACKAGED_URL, } from '../../../../common/constants'; +import * as i18n from '../../../pages/detection_engine/rules/translations'; /** * Add provided Rule @@ -223,3 +227,78 @@ export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promi await throwIfNotOk(response); return true; }; + +/** + * Imports rules in the same format as exported via the _export API + * + * @param fileToImport File to upload containing rules to import + * @param overwrite whether or not to overwrite rules with the same ruleId + * @param signal AbortSignal for cancelling request + * + * @throws An error if response is not OK + */ +export const importRules = async ({ + fileToImport, + overwrite = false, + signal, +}: ImportRulesProps): Promise => { + const formData = new FormData(); + formData.append('file', fileToImport); + + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_import?overwrite=${overwrite}`, + { + method: 'POST', + credentials: 'same-origin', + headers: { + 'kbn-xsrf': 'true', + }, + body: formData, + signal, + } + ); + + await throwIfNotOk(response); + return response.json(); +}; + +/** + * Export rules from the server as a file download + * + * @param excludeExportDetails whether or not to exclude additional details at bottom of exported file (defaults to false) + * @param filename of exported rules. Be sure to include `.ndjson` extension! (defaults to localized `rules_export.ndjson`) + * @param ruleIds array of rule_id's (not id!) to export (empty array exports _all_ rules) + * @param signal AbortSignal for cancelling request + * + * @throws An error if response is not OK + */ +export const exportRules = async ({ + excludeExportDetails = false, + filename = `${i18n.EXPORT_FILENAME}.ndjson`, + ruleIds = [], + signal, +}: ExportRulesProps): Promise => { + const body = + ruleIds.length > 0 + ? JSON.stringify({ objects: ruleIds.map(rule => ({ rule_id: rule })) }) + : undefined; + + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_export?exclude_export_details=${excludeExportDetails}&file_name=${encodeURIComponent( + filename + )}`, + { + method: 'POST', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + body, + signal, + } + ); + + await throwIfNotOk(response); + return response.blob(); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 147b04567f6c7..7714779edf057 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -148,3 +148,30 @@ export interface DuplicateRulesProps { export interface BasicFetchProps { signal: AbortSignal; } + +export interface ImportRulesProps { + fileToImport: File; + overwrite?: boolean; + signal: AbortSignal; +} + +export interface ImportRulesResponseError { + rule_id: string; + error: { + status_code: number; + message: string; + }; +} + +export interface ImportRulesResponse { + success: boolean; + success_count: number; + errors: ImportRulesResponseError[]; +} + +export interface ExportRulesProps { + ruleIds?: string[]; + filename?: string; + excludeExportDetails?: boolean; + signal: AbortSignal; +} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx index 3356ef101677d..0971ef0149304 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx @@ -51,7 +51,7 @@ export const getBatchItems = ( { closePopover(); await exportRulesAction( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 0c1804f26ecdd..ed5dc6913151a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -64,6 +64,7 @@ const getActions = ( icon: 'exportAction', name: i18n.EXPORT_RULE, onClick: (rowItem: TableData) => exportRulesAction([rowItem.sourceRule], dispatch), + enabled: (rowItem: TableData) => !rowItem.immutable, }, { description: i18n.DELETE_RULE, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 819b513ecc9bc..cb4ffa127781d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -31,7 +31,7 @@ import { getBatchItems } from './batch_actions'; import { EuiBasicTableOnChange, TableData } from '../types'; import { allRulesReducer, State } from './reducer'; import * as i18n from '../translations'; -import { JSONDownloader } from '../components/json_downloader'; +import { RuleDownloader } from '../components/rule_downloader'; import { useStateToaster } from '../../../../components/toasters'; const initialState: State = { @@ -150,9 +150,9 @@ export const AllRules = React.memo<{ return ( <> - { dispatchToaster({ type: 'addToaster', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/reducer.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/reducer.ts index 4a6be1a7e4da7..22d6ca2195fe6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/reducer.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/reducer.ts @@ -20,7 +20,7 @@ export interface State { filterOptions: FilterOptions; refreshToggle: boolean; tableData: TableData[]; - exportPayload?: object[]; + exportPayload?: Rule[]; } export type Action = @@ -28,7 +28,7 @@ export type Action = | { type: 'loading'; isLoading: boolean } | { type: 'deleteRules'; rules: Rule[] } | { type: 'duplicate'; rule: Rule } - | { type: 'setExportPayload'; exportPayload?: object[] } + | { type: 'setExportPayload'; exportPayload?: Rule[] } | { type: 'setSelected'; selectedItems: TableData[] } | { type: 'updateLoading'; ids: string[]; isLoading: boolean } | { type: 'updateRules'; rules: Rule[]; appendRuleId?: string; pagination?: PaginationOptions } @@ -143,7 +143,7 @@ export const allRulesReducer = (state: State, action: Action): State => { case 'setExportPayload': { return { ...state, - exportPayload: action.exportPayload, + exportPayload: [...(action.exportPayload ?? [])], }; } default: diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap index 6b0aa02d4edfa..64b88912e53a3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap @@ -28,9 +28,8 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` display="large" fullWidth={true} id="rule-file-picker" - initialPromptText="Select or drag and drop files" + initialPromptText="Select or drag and drop a valid rules_export.ndjson file" isLoading={false} - multiple={true} onChange={[Function]} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx index 75be92f2fe846..91b2ee283609f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx @@ -8,28 +8,25 @@ import { EuiButton, EuiButtonEmpty, EuiCheckbox, + // @ts-ignore no-exported-member + EuiFilePicker, EuiModal, EuiModalBody, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, EuiOverlayMask, - // @ts-ignore no-exported-member - EuiFilePicker, EuiSpacer, EuiText, } from '@elastic/eui'; -import { noop } from 'lodash/fp'; import React, { useCallback, useState } from 'react'; -import { failure } from 'io-ts/lib/PathReporter'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import uuid from 'uuid'; - -import { duplicateRules, RulesSchema } from '../../../../../containers/detection_engine/rules'; -import { useStateToaster } from '../../../../../components/toasters'; -import { ndjsonToJSON } from '../json_downloader'; + +import { importRules } from '../../../../../containers/detection_engine/rules'; +import { + displayErrorToast, + displaySuccessToast, + useStateToaster, +} from '../../../../../components/toasters'; import * as i18n from './translations'; interface ImportRuleModalProps { @@ -40,10 +37,6 @@ interface ImportRuleModalProps { /** * Modal component for importing Rules from a json file - * - * @param filename name of file to be downloaded - * @param payload JSON string to write to file - * */ export const ImportRuleModalComponent = ({ showModal, @@ -52,6 +45,7 @@ export const ImportRuleModalComponent = ({ }: ImportRuleModalProps) => { const [selectedFiles, setSelectedFiles] = useState(null); const [isImporting, setIsImporting] = useState(false); + const [overwrite, setOverwrite] = useState(false); const [, dispatchToaster] = useStateToaster(); const cleanupAndCloseModal = () => { @@ -60,49 +54,41 @@ export const ImportRuleModalComponent = ({ closeModal(); }; - const importRules = useCallback(async () => { + const importRulesCallback = useCallback(async () => { if (selectedFiles != null) { setIsImporting(true); - const reader = new FileReader(); - reader.onload = async event => { - // @ts-ignore type is string, not ArrayBuffer as FileReader.readAsText is called - const importedRules = ndjsonToJSON(event?.target?.result ?? ''); - - const decodedRules = pipe( - RulesSchema.decode(importedRules), - fold(errors => { - cleanupAndCloseModal(); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.IMPORT_FAILED, - color: 'danger', - iconType: 'alert', - errors: failure(errors), - }, - }); - throw new Error(failure(errors).join('\n')); - }, identity) - ); - - const duplicatedRules = await duplicateRules({ rules: decodedRules }); - importComplete(); - cleanupAndCloseModal(); + const abortCtrl = new AbortController(); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_IMPORTED_RULES(duplicatedRules.length), - color: 'success', - iconType: 'check', - }, + try { + const importResponse = await importRules({ + fileToImport: selectedFiles[0], + overwrite, + signal: abortCtrl.signal, }); - }; - Object.values(selectedFiles).map(f => reader.readAsText(f)); + + // TODO: Improve error toast details for better debugging failed imports + // e.g. When success == true && success_count === 0 that means no rules were overwritten, etc + if (importResponse.success) { + displaySuccessToast( + i18n.SUCCESSFULLY_IMPORTED_RULES(importResponse.success_count), + dispatchToaster + ); + } + if (importResponse.errors.length > 0) { + const formattedErrors = importResponse.errors.map(e => + i18n.IMPORT_FAILED_DETAILED(e.rule_id, e.error.status_code, e.error.message) + ); + displayErrorToast(i18n.IMPORT_FAILED, formattedErrors, dispatchToaster); + } + + importComplete(); + cleanupAndCloseModal(); + } catch (e) { + cleanupAndCloseModal(); + displayErrorToast(i18n.IMPORT_FAILED, [e.message], dispatchToaster); + } } - }, [selectedFiles]); + }, [selectedFiles, overwrite]); return ( <> @@ -121,7 +107,6 @@ export const ImportRuleModalComponent = ({ { setSelectedFiles(Object.keys(files).length > 0 ? files : null); @@ -134,14 +119,18 @@ export const ImportRuleModalComponent = ({ noop} + checked={overwrite} + onChange={() => setOverwrite(!overwrite)} /> {i18n.CANCEL_BUTTON} - + {i18n.IMPORT_RULE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts index 50c3c75b6109f..dab1c9490591f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts @@ -23,14 +23,14 @@ export const SELECT_RULE = i18n.translate( export const INITIAL_PROMPT_TEXT = i18n.translate( 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription', { - defaultMessage: 'Select or drag and drop files', + defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file', } ); export const OVERWRITE_WITH_SAME_NAME = i18n.translate( 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription', { - defaultMessage: 'Automatically overwrite saved objects with the same name', + defaultMessage: 'Automatically overwrite saved objects with the same rule ID', } ); @@ -57,3 +57,12 @@ export const IMPORT_FAILED = i18n.translate( defaultMessage: 'Failed to import rules', } ); + +export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) => + i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle', + { + values: { ruleId, statusCode, message }, + defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', + } + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/__snapshots__/index.test.tsx.snap deleted file mode 100644 index c4377c265c2c2..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`JSONDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.test.tsx deleted file mode 100644 index 859918cdc8e60..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.test.tsx +++ /dev/null @@ -1,60 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; -import { JSONDownloaderComponent, jsonToNDJSON, ndjsonToJSON } from './index'; - -const jsonArray = [ - { - description: 'Detecting root and admin users1', - created_by: 'elastic', - false_positives: [], - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - max_signals: 100, - }, - { - description: 'Detecting root and admin users2', - created_by: 'elastic', - false_positives: [], - index: ['auditbeat-*', 'packetbeat-*', 'winlogbeat-*'], - max_signals: 101, - }, -]; - -const ndjson = `{"description":"Detecting root and admin users1","created_by":"elastic","false_positives":[],"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"max_signals":100} -{"description":"Detecting root and admin users2","created_by":"elastic","false_positives":[],"index":["auditbeat-*","packetbeat-*","winlogbeat-*"],"max_signals":101}`; - -const ndjsonSorted = `{"created_by":"elastic","description":"Detecting root and admin users1","false_positives":[],"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"max_signals":100} -{"created_by":"elastic","description":"Detecting root and admin users2","false_positives":[],"index":["auditbeat-*","packetbeat-*","winlogbeat-*"],"max_signals":101}`; - -describe('JSONDownloader', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - ); - expect(wrapper).toMatchSnapshot(); - }); - - describe('jsonToNDJSON', () => { - test('converts to NDJSON', () => { - const output = jsonToNDJSON(jsonArray, false); - expect(output).toEqual(ndjson); - }); - - test('converts to NDJSON with keys sorted', () => { - const output = jsonToNDJSON(jsonArray); - expect(output).toEqual(ndjsonSorted); - }); - }); - - describe('ndjsonToJSON', () => { - test('converts to JSON', () => { - const output = ndjsonToJSON(ndjson); - expect(output).toEqual(jsonArray); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.tsx deleted file mode 100644 index 2810e0b5e1680..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/json_downloader/index.tsx +++ /dev/null @@ -1,75 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; - -const InvisibleAnchor = styled.a` - display: none; -`; - -export interface JSONDownloaderProps { - filename: string; - payload?: object[]; - onExportComplete: (exportCount: number) => void; -} - -/** - * Component for downloading JSON as a file. Download will occur on each update to `payload` param - * - * @param filename name of file to be downloaded - * @param payload JSON string to write to file - * - */ -export const JSONDownloaderComponent = ({ - filename, - payload, - onExportComplete, -}: JSONDownloaderProps) => { - const anchorRef = useRef(null); - - useEffect(() => { - if (anchorRef && anchorRef.current && payload != null) { - const blob = new Blob([jsonToNDJSON(payload)], { type: 'application/json' }); - // @ts-ignore function is not always defined -- this is for supporting IE - if (window.navigator.msSaveOrOpenBlob) { - window.navigator.msSaveBlob(blob); - } else { - const objectURL = window.URL.createObjectURL(blob); - anchorRef.current.href = objectURL; - anchorRef.current.download = filename; - anchorRef.current.click(); - window.URL.revokeObjectURL(objectURL); - } - onExportComplete(payload.length); - } - }, [payload]); - - return ; -}; - -JSONDownloaderComponent.displayName = 'JSONDownloaderComponent'; - -export const JSONDownloader = React.memo(JSONDownloaderComponent); - -JSONDownloader.displayName = 'JSONDownloader'; - -export const jsonToNDJSON = (jsonArray: object[], sortKeys = true): string => { - return jsonArray - .map(j => JSON.stringify(j, sortKeys ? Object.keys(j).sort() : null, 0)) - .join('\n'); -}; - -export const ndjsonToJSON = (ndjson: string): object[] => { - const jsonLines = ndjson.split(/\r?\n/); - return jsonLines.reduce((acc, line) => { - try { - return [...acc, JSON.parse(line)]; - } catch (e) { - return acc; - } - }, []); -}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..4259b68bf14a2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RuleDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx new file mode 100644 index 0000000000000..6306260dfc872 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx @@ -0,0 +1,18 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { RuleDownloaderComponent } from './index'; + +describe('RuleDownloader', () => { + test('renders correctly against snapshot', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx new file mode 100644 index 0000000000000..b41265adea6b1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx @@ -0,0 +1,89 @@ +/* + * 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 React, { useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { isFunction } from 'lodash/fp'; +import { exportRules, Rule } from '../../../../../containers/detection_engine/rules'; +import { displayErrorToast, useStateToaster } from '../../../../../components/toasters'; +import * as i18n from './translations'; + +const InvisibleAnchor = styled.a` + display: none; +`; + +export interface RuleDownloaderProps { + filename: string; + rules?: Rule[]; + onExportComplete: (exportCount: number) => void; +} + +/** + * Component for downloading Rules as an exported .ndjson file. Download will occur on each update to `rules` param + * + * @param filename of file to be downloaded + * @param payload Rule[] + * + */ +export const RuleDownloaderComponent = ({ + filename, + rules, + onExportComplete, +}: RuleDownloaderProps) => { + const anchorRef = useRef(null); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + async function exportData() { + if (anchorRef && anchorRef.current && rules != null) { + try { + const exportResponse = await exportRules({ + ruleIds: rules.map(r => r.rule_id), + signal: abortCtrl.signal, + }); + + if (isSubscribed) { + // this is for supporting IE + if (isFunction(window.navigator.msSaveOrOpenBlob)) { + window.navigator.msSaveBlob(exportResponse); + } else { + const objectURL = window.URL.createObjectURL(exportResponse); + // These are safe-assignments as writes to anchorRef are isolated to exportData + anchorRef.current.href = objectURL; // eslint-disable-line require-atomic-updates + anchorRef.current.download = filename; // eslint-disable-line require-atomic-updates + anchorRef.current.click(); + window.URL.revokeObjectURL(objectURL); + } + + onExportComplete(rules.length); + } + } catch (error) { + if (isSubscribed) { + displayErrorToast(i18n.EXPORT_FAILURE, [error.message], dispatchToaster); + } + } + } + } + + exportData(); + + return () => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [rules]); + + return ; +}; + +RuleDownloaderComponent.displayName = 'RuleDownloaderComponent'; + +export const RuleDownloader = React.memo(RuleDownloaderComponent); + +RuleDownloader.displayName = 'RuleDownloader'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts new file mode 100644 index 0000000000000..72efefa1c461b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const EXPORT_FAILURE = i18n.translate( + 'xpack.siem.detectionEngine.rules.components.ruleDownloader.exportFailureTitle', + { + defaultMessage: 'Failed to export rules…', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index d55e08e9ecd73..1e47d1a57facc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -123,7 +123,7 @@ export const SUCCESSFULLY_EXPORTED_RULES = (totalRules: number) => i18n.translate('xpack.siem.detectionEngine.rules.allRules.successfullyExportedRulesTitle', { values: { totalRules }, defaultMessage: - 'Successfully exported {totalRules} {totalRules, plural, =1 {rule} other {rules}}', + 'Successfully exported {totalRules, plural, =0 {all rules} =1 {{totalRules} rule} other {{totalRules} rules}}', }); export const ALL_RULES = i18n.translate('xpack.siem.detectionEngine.rules.allRules.tableTitle', {