From d686a3f39fa375fcdf47810aa36c86e0f6eb6ecc Mon Sep 17 00:00:00 2001 From: Antonio Date: Thu, 2 Jan 2025 14:13:47 +0100 Subject: [PATCH] [ResponseOps][Cases] Add additional fields to ServiceNow cases integration (#201948) Closes https://github.com/elastic/enhancements/issues/22091 ## Summary The ServiceNow ITSM and SecOps connector for cases now supports the `Additional fields` JSON field. This is an object where the keys correspond to the internal names of the table columns in ServiceNow. ## How to test 1. Cases with an existing ServiceNow connector configuration should not break. 2. The additional fields' validation works as expected. 3. Adding additional fields to the ServiceNow connector works as expected and these fields are sent to ServiceNow. Testing can be tricky because ServiceNow ignores additional fields where the key is not known or the value is not accepted. You need to make sure the key matches an existing column and that the value is allowed **on ServiceNow**. ### SecOps The original issue concerned the fields `Configuration item`, `Affected user`, and `Location` so these must work. An example request **for SecOps** with these fields' keys is the following: ``` { "u_cmdb_ci": "*ANNIE-IBM", "u_location": "815 E Street, San Diego,CA", "u_affected_user": "Antonio Coelho" } ``` This should result in: Screenshot 2024-11-27 at 12 52 37 **The tricky part here is that they should be the names of existing resources in ServiceNow so the values cannot be arbitrary.** ### ITSM ITSM fields are different than the ones in SecOps. An example object is: ``` { "u_assignment_group": "Database" } ``` This results in: Screenshot 2024-11-27 at 13 46 56 ## Release Notes Pass any field to ServiceNow using the ServiceNow SecOps connector with a JSON field called "additional fields". --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../cases/common/types/domain/connector/v1.ts | 46 +++++--- .../configure_cases/flyout.test.tsx | 1 + .../components/connectors/card.test.tsx | 28 +++++ .../public/components/connectors/card.tsx | 39 +++++-- .../servicenow/json_editor_field.test.tsx | 80 +++++++++++++ .../servicenow/json_editor_field.tsx | 106 ++++++++++++++++++ .../servicenow_itsm_case_fields.test.tsx | 40 ++++--- .../servicenow_itsm_case_fields.tsx | 29 +++++ ...rvicenow_itsm_case_fields_preview.test.tsx | 3 + .../servicenow_itsm_case_fields_preview.tsx | 11 ++ .../servicenow_sir_case_fields.test.tsx | 2 + .../servicenow/servicenow_sir_case_fields.tsx | 29 +++++ ...ervicenow_sir_case_fields_preview.test.tsx | 3 + .../servicenow_sir_case_fields_preview.tsx | 11 ++ .../connectors/servicenow/translations.ts | 20 ++++ .../servicenow/validate_json.test.ts | 70 ++++++++++++ .../connectors/servicenow/validate_json.ts | 41 +++++++ .../components/create/form_context.test.tsx | 4 +- .../edit_connector/connectors_form.test.tsx | 19 ++-- .../edit_connector/connectors_form.tsx | 3 +- .../cases/server/client/cases/utils.test.ts | 2 + .../connectors/servicenow/itsm_format.test.ts | 18 ++- .../connectors/servicenow/itsm_format.ts | 2 + .../connectors/servicenow/sir_format.test.ts | 6 + .../connectors/servicenow/sir_format.ts | 2 + .../server/connectors/servicenow/types.ts | 21 +++- .../plugins/shared/cases/tsconfig.json | 3 + 27 files changed, 576 insertions(+), 63 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.test.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.test.ts create mode 100644 x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.ts diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/connector/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/connector/v1.ts index 9d21f034d6397..6d422024a4b0b 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/connector/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/connector/v1.ts @@ -66,13 +66,20 @@ const ConnectorResilientTypeFieldsRt = rt.strict({ * ServiceNow */ -export const ServiceNowITSMFieldsRt = rt.strict({ - impact: rt.union([rt.string, rt.null]), - severity: rt.union([rt.string, rt.null]), - urgency: rt.union([rt.string, rt.null]), - category: rt.union([rt.string, rt.null]), - subcategory: rt.union([rt.string, rt.null]), -}); +export const ServiceNowITSMFieldsRt = rt.intersection([ + rt.strict({ + impact: rt.union([rt.string, rt.null]), + severity: rt.union([rt.string, rt.null]), + urgency: rt.union([rt.string, rt.null]), + category: rt.union([rt.string, rt.null]), + subcategory: rt.union([rt.string, rt.null]), + }), + rt.exact( + rt.partial({ + additionalFields: rt.union([rt.string, rt.null]), + }) + ), +]); export type ServiceNowITSMFieldsType = rt.TypeOf; @@ -81,15 +88,22 @@ const ConnectorServiceNowITSMTypeFieldsRt = rt.strict({ fields: rt.union([ServiceNowITSMFieldsRt, rt.null]), }); -export const ServiceNowSIRFieldsRt = rt.strict({ - category: rt.union([rt.string, rt.null]), - destIp: rt.union([rt.boolean, rt.null]), - malwareHash: rt.union([rt.boolean, rt.null]), - malwareUrl: rt.union([rt.boolean, rt.null]), - priority: rt.union([rt.string, rt.null]), - sourceIp: rt.union([rt.boolean, rt.null]), - subcategory: rt.union([rt.string, rt.null]), -}); +export const ServiceNowSIRFieldsRt = rt.intersection([ + rt.strict({ + category: rt.union([rt.string, rt.null]), + destIp: rt.union([rt.boolean, rt.null]), + malwareHash: rt.union([rt.boolean, rt.null]), + malwareUrl: rt.union([rt.boolean, rt.null]), + priority: rt.union([rt.string, rt.null]), + sourceIp: rt.union([rt.boolean, rt.null]), + subcategory: rt.union([rt.string, rt.null]), + }), + rt.exact( + rt.partial({ + additionalFields: rt.union([rt.string, rt.null]), + }) + ), +]); export type ServiceNowSIRFieldsType = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx index 0920950ed65b2..57bd1ab0fa431 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx @@ -682,6 +682,7 @@ describe('CommonFlyout ', () => { impact: null, category: 'software', subcategory: null, + additionalFields: null, }, }, settings: { diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/card.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/card.test.tsx index cdc658a29a404..725b2be28df48 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/card.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/card.test.tsx @@ -74,4 +74,32 @@ describe('ConnectorCard ', () => { expect(getByText(`${item.title}: ${item.description}`)).toBeInTheDocument(); } }); + + it('shows a codeblock when applicable', async () => { + render( + + ); + + expect(await screen.findByTestId('card-list-item')).toBeInTheDocument(); + expect(await screen.findByTestId('card-list-code-block')).toBeInTheDocument(); + }); + + it('does not show a codeblock when not necessary', async () => { + render( + + ); + + expect(await screen.findByTestId('card-list-item')).toBeInTheDocument(); + expect(screen.queryByTestId('card-list-code-block')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/card.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/card.tsx index 47ca384d175e6..c4f0f53a8e208 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/card.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/card.tsx @@ -6,7 +6,14 @@ */ import React, { memo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSkeletonText, EuiText } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSkeletonText, + EuiText, + EuiCodeBlock, +} from '@elastic/eui'; import type { ConnectorTypes } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; @@ -15,7 +22,7 @@ import { getConnectorIcon } from '../utils'; interface ConnectorCardProps { connectorType: ConnectorTypes; title: string; - listItems: Array<{ title: string; description: React.ReactNode }>; + listItems: Array<{ title: string; description: React.ReactNode; displayAsCodeBlock?: boolean }>; isLoading: boolean; } @@ -47,12 +54,28 @@ const ConnectorCardDisplay: React.FC = ({ {listItems.length > 0 && - listItems.map((item, i) => ( - - {`${item.title}: `} - {`${item.description}`} - - ))} + listItems.map((item, i) => + item.displayAsCodeBlock ? ( + <> + + {`${item.title}:`} + + + {`${item.description}`} + + + ) : ( + + {`${item.title}: `} + {`${item.description}`} + + ) + )} diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.test.tsx new file mode 100644 index 0000000000000..740baab98e3c0 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { type ComponentProps } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { JsonEditorField } from './json_editor_field'; +import { MockedCodeEditor } from '@kbn/code-editor-mock'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { MockedMonacoEditor } from '@kbn/code-editor-mock/monaco_mock'; + +jest.mock('@kbn/code-editor', () => { + const original = jest.requireActual('@kbn/code-editor'); + return { + ...original, + CodeEditor: (props: ComponentProps) => ( + + ), + }; +}); + +const setXJson = jest.fn(); +const XJson = { + useXJsonMode: (value: unknown) => ({ + convertToJson: (toJson: unknown) => toJson, + setXJson, + xJson: value, + }), +}; + +jest.mock('@kbn/es-ui-shared-plugin/public', () => { + const original = jest.requireActual('@kbn/es-ui-shared-plugin/public'); + return { + ...original, + XJson, + }; +}); + +describe('JsonEditorField', () => { + const setValue = jest.fn(); + const props = { + field: { + label: 'my label', + helpText: 'help', + value: 'foobar', + setValue, + errors: [], + } as unknown as FieldHook, + paramsProperty: 'myField', + label: 'label', + dataTestSubj: 'foobarTestSubj', + }; + + beforeEach(() => jest.resetAllMocks()); + + it('renders as expected', async () => { + render(); + + expect(await screen.findByTestId('foobarTestSubj')).toBeInTheDocument(); + expect(await screen.findByTestId('myFieldJsonEditor')).toBeInTheDocument(); + expect(await screen.findByText('my label')).toBeInTheDocument(); + }); + + it('calls setValue and xJson on editor change', async () => { + render(); + + await userEvent.click(await screen.findByTestId('myFieldJsonEditor')); + await userEvent.paste('JSON'); + + await waitFor(() => { + expect(setValue).toBeCalledWith('foobarJSON'); + }); + + expect(setXJson).toBeCalledWith('foobarJSON'); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.tsx new file mode 100644 index 0000000000000..c0d425d35de28 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/json_editor_field.tsx @@ -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 React, { useCallback, useEffect } from 'react'; +import { EuiFormRow } from '@elastic/eui'; + +import { XJsonLang } from '@kbn/monaco'; + +import { XJson } from '@kbn/es-ui-shared-plugin/public'; +import { CodeEditor } from '@kbn/code-editor'; + +import { + getFieldValidityAndErrorMessage, + type FieldHook, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { i18n } from '@kbn/i18n'; + +interface Props { + field: FieldHook; + paramsProperty: string; + ariaLabel?: string; + onBlur?: () => void; + dataTestSubj?: string; + euiCodeEditorProps?: { [key: string]: unknown }; +} + +const { useXJsonMode } = XJson; + +export const JsonEditorField: React.FunctionComponent = ({ + field, + paramsProperty, + ariaLabel, + dataTestSubj, + euiCodeEditorProps = {}, +}) => { + const { label: fieldLabel, helpText, value: inputTargetValue, setValue } = field; + const { errorMessage } = getFieldValidityAndErrorMessage(field); + + const onDocumentsChange = useCallback( + (updatedJson: string) => { + setValue(updatedJson); + }, + [setValue] + ); + const errors = errorMessage ? [errorMessage] : []; + + const label = + fieldLabel ?? + i18n.translate('xpack.cases.jsonEditorField.defaultLabel', { + defaultMessage: 'JSON Editor', + }); + + const { convertToJson, setXJson, xJson } = useXJsonMode(inputTargetValue ?? null); + + useEffect(() => { + if (!xJson && inputTargetValue) { + setXJson(inputTargetValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputTargetValue]); + + return ( + 0 && inputTargetValue !== undefined} + label={label} + helpText={helpText} + > + { + setXJson(xjson); + // Keep the documents in sync with the editor content + onDocumentsChange(convertToJson(xjson)); + }} + /> + + ); +}; + +JsonEditorField.displayName = 'JsonEditorField'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index c807152ba84a9..fbdbf60d507fb 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -20,10 +20,15 @@ jest.mock('../../../common/lib/kibana'); jest.mock('./use_get_choices'); const useGetChoicesMock = useGetChoices as jest.Mock; -let appMockRenderer: AppMockRenderer; +useGetChoicesMock.mockReturnValue({ + isLoading: false, + isFetching: false, + data: { data: choices }, +}); describe('ServiceNowITSM Fields', () => { let user: UserEvent; + const appMockRenderer: AppMockRenderer = createAppMockRenderer(); beforeAll(() => { jest.useFakeTimers(); @@ -39,6 +44,7 @@ describe('ServiceNowITSM Fields', () => { impact: '3', category: 'software', subcategory: 'os', + additionalFields: '', }; beforeEach(() => { @@ -46,12 +52,9 @@ describe('ServiceNowITSM Fields', () => { user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, }); - appMockRenderer = createAppMockRenderer(); - useGetChoicesMock.mockReturnValue({ - isLoading: false, - isFetching: false, - data: { data: choices }, - }); + }); + + afterEach(() => { jest.clearAllMocks(); }); @@ -62,11 +65,12 @@ describe('ServiceNowITSM Fields', () => { ); - expect(await screen.findByTestId('severitySelect')).toBeInTheDocument(); - expect(await screen.findByTestId('urgencySelect')).toBeInTheDocument(); - expect(await screen.findByTestId('impactSelect')).toBeInTheDocument(); - expect(await screen.findByTestId('categorySelect')).toBeInTheDocument(); - expect(await screen.findByTestId('subcategorySelect')).toBeInTheDocument(); + expect(screen.getByTestId('severitySelect')).toBeInTheDocument(); + expect(screen.getByTestId('urgencySelect')).toBeInTheDocument(); + expect(screen.getByTestId('impactSelect')).toBeInTheDocument(); + expect(screen.getByTestId('categorySelect')).toBeInTheDocument(); + expect(screen.getByTestId('subcategorySelect')).toBeInTheDocument(); + expect(screen.getByTestId('additionalFieldsEditor')).toBeInTheDocument(); }); it('transforms the categories to options correctly', async () => { @@ -76,11 +80,13 @@ describe('ServiceNowITSM Fields', () => { ); - expect(await screen.findByRole('option', { name: 'Privilege Escalation' })); - expect(await screen.findByRole('option', { name: 'Criminal activity/investigation' })); - expect(await screen.findByRole('option', { name: 'Denial of Service' })); - expect(await screen.findByRole('option', { name: 'Software' })); - expect(await screen.findByRole('option', { name: 'Failed Login' })); + const categorySelect = screen.getByTestId('categorySelect'); + + expect(within(categorySelect).getByRole('option', { name: 'Privilege Escalation' })); + expect(within(categorySelect).getByRole('option', { name: 'Criminal activity/investigation' })); + expect(within(categorySelect).getByRole('option', { name: 'Denial of Service' })); + expect(within(categorySelect).getByRole('option', { name: 'Software' })); + expect(within(categorySelect).getByRole('option', { name: 'Failed Login' })); }); it('transforms the subcategories to options correctly', async () => { diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 7d6981fda05e4..75a33a1bc21b5 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -22,6 +22,8 @@ import { useGetChoices } from './use_get_choices'; import type { Fields } from './types'; import { choicesToEuiOptions } from './helpers'; import { DeprecatedCallout } from '../deprecated_callout'; +import { validateJSON } from './validate_json'; +import { JsonEditorField } from './json_editor_field'; const choicesToGet = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -205,6 +207,33 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent + + + + + ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.test.tsx index bffb0f2d0500d..27f5670e47071 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.test.tsx @@ -26,6 +26,7 @@ describe('ServiceNowITSM Fields: Preview', () => { impact: '3', category: 'Denial of Service', subcategory: '12', + additionalFields: '{"foo": "bar"}', }; let appMockRenderer: AppMockRenderer; @@ -50,5 +51,7 @@ describe('ServiceNowITSM Fields: Preview', () => { expect(getByText('Impact: 3 - Moderate')).toBeInTheDocument(); expect(getByText('Category: Denial of Service')).toBeInTheDocument(); expect(getByText('Subcategory: Inbound or outbound')).toBeInTheDocument(); + expect(getByText('Additional Fields:')).toBeInTheDocument(); + expect(getByText('{"foo": "bar"}')).toBeInTheDocument(); }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.tsx index c88960b15f94f..8c1b4fd4f86fb 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields_preview.tsx @@ -37,6 +37,7 @@ const ServiceNowITSMFieldsPreviewComponent: React.FunctionComponent< impact = null, category = null, subcategory = null, + additionalFields = null, } = fields ?? {}; const { http } = useKibana().services; @@ -134,8 +135,18 @@ const ServiceNowITSMFieldsPreviewComponent: React.FunctionComponent< }, ] : []), + ...(additionalFields != null && additionalFields.length > 0 + ? [ + { + title: i18n.ADDITIONAL_FIELDS_LABEL, + description: additionalFields, + displayAsCodeBlock: true, + }, + ] + : []), ], [ + additionalFields, category, categoryOptions, impact, diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index f60a06521c472..ee7719f14547f 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -32,6 +32,7 @@ describe('ServiceNowSIR Fields', () => { priority: '1', category: 'Denial of Service', subcategory: '26', + additionalFields: '{}', }; beforeAll(() => { @@ -68,6 +69,7 @@ describe('ServiceNowSIR Fields', () => { expect(screen.getByTestId('prioritySelect')).toBeInTheDocument(); expect(screen.getByTestId('categorySelect')).toBeInTheDocument(); expect(screen.getByTestId('subcategorySelect')).toBeInTheDocument(); + expect(screen.getByTestId('additionalFieldsEditor')).toBeInTheDocument(); }); it('transforms the categories to options correctly', async () => { diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index e07fcc204c9da..5b90bd753c4d3 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -23,6 +23,8 @@ import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; import { DeprecatedCallout } from '../deprecated_callout'; +import { validateJSON } from './validate_json'; +import { JsonEditorField } from './json_editor_field'; const choicesToGet = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -223,6 +225,33 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent + + + + + ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.test.tsx index 7a0cce7ae4e9d..4d5eb24bb8ffd 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.test.tsx @@ -28,6 +28,7 @@ describe('ServiceNowITSM Fields: Preview', () => { priority: '2', category: 'Denial of Service', subcategory: '12', + additionalFields: '{"foo": "bar"}', }; let appMockRenderer: AppMockRenderer; @@ -54,5 +55,7 @@ describe('ServiceNowITSM Fields: Preview', () => { expect(getByText('Priority: 2 - High')).toBeInTheDocument(); expect(getByText('Category: Denial of Service')).toBeInTheDocument(); expect(getByText('Subcategory: Inbound or outbound')).toBeInTheDocument(); + expect(getByText('Additional Fields:')).toBeInTheDocument(); + expect(getByText('{"foo": "bar"}')).toBeInTheDocument(); }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.tsx b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.tsx index e89bff6251718..a84654c0bbaac 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/servicenow_sir_case_fields_preview.tsx @@ -38,6 +38,7 @@ const ServiceNowSIRFieldsPreviewComponent: React.FunctionComponent< priority = null, sourceIp = true, subcategory = null, + additionalFields = null, } = fields ?? {}; const { http } = useKibana().services; @@ -140,6 +141,15 @@ const ServiceNowSIRFieldsPreviewComponent: React.FunctionComponent< }, ] : []), + ...(additionalFields != null && additionalFields.length > 0 + ? [ + { + title: i18n.ADDITIONAL_FIELDS_LABEL, + description: additionalFields, + displayAsCodeBlock: true, + }, + ] + : []), ], [ category, @@ -152,6 +162,7 @@ const ServiceNowSIRFieldsPreviewComponent: React.FunctionComponent< sourceIp, subcategory, subcategoryOptions, + additionalFields, ] ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/translations.ts index d9ed86b594ecc..50f7c9679ccfb 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/translations.ts @@ -73,3 +73,23 @@ export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( defaultMessage: 'Yes', } ); + +export const ADDITIONAL_FIELDS_LABEL = i18n.translate( + 'xpack.cases.connectors.serviceNow.additionalFieldsLabel', + { + defaultMessage: 'Additional Fields', + } +); + +export const INVALID_JSON_FORMAT = i18n.translate( + 'xpack.cases.connectors.serviceNow.additionalFieldsFormatErrorMessage', + { + defaultMessage: 'Invalid JSON.', + } +); + +export const MAX_ATTRIBUTES_ERROR = (length: number) => + i18n.translate('xpack.cases.connectors.serviceNow.additionalFieldsLengthError', { + values: { length }, + defaultMessage: 'A maximum of {length} additional fields can be defined at a time.', + }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.test.ts b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.test.ts new file mode 100644 index 0000000000000..7ae94e8adf3b2 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { ValidationFuncArg } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types'; +import { validateJSON } from './validate_json'; + +describe('validateJSON', () => { + const formData = {} as ValidationFuncArg; + + it('does not return an error for valid JSON with less than maxProperties', () => { + expect(validateJSON({ ...formData, value: JSON.stringify({ foo: 'test' }) })).toBeUndefined(); + }); + + it('does not return an error with an empty string value', () => { + expect(validateJSON({ ...formData, value: '' })).toBeUndefined(); + }); + + it('does not return an error with undefined value', () => { + expect(validateJSON(formData)).toBeUndefined(); + }); + + it('does not return an error with a null value', () => { + expect(validateJSON({ ...formData, value: null })).toBeUndefined(); + }); + + it('validates syntax errors correctly', () => { + expect(validateJSON({ ...formData, value: 'foo' })).toEqual({ + code: 'ERR_JSON_FORMAT', + message: 'Invalid JSON.', + }); + }); + + it('validates a string with spaces correctly', () => { + expect(validateJSON({ ...formData, value: ' ' })).toEqual({ + code: 'ERR_JSON_FORMAT', + message: 'Invalid JSON.', + }); + }); + + it('validates max properties correctly', () => { + let value = '{"a":"1"'; + for (let i = 0; i < 10; i += 1) { + value = `${value}, "${i}": "foobar"`; + } + value += '}'; + + expect(validateJSON({ ...formData, value })).toEqual({ + code: 'ERR_JSON_FORMAT', + message: 'A maximum of 10 additional fields can be defined at a time.', + }); + }); + + it('throws when a non object string is found', () => { + expect(validateJSON({ ...formData, value: '"foobar"' })).toEqual({ + code: 'ERR_JSON_FORMAT', + message: 'Invalid JSON.', + }); + }); + + it('throws when a non object empty string is found', () => { + expect(validateJSON({ ...formData, value: '""' })).toEqual({ + code: 'ERR_JSON_FORMAT', + message: 'Invalid JSON.', + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.ts b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.ts new file mode 100644 index 0000000000000..369663e078919 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/connectors/servicenow/validate_json.ts @@ -0,0 +1,41 @@ +/* + * 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 { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { isEmpty, isObject } from 'lodash'; +import * as i18n from './translations'; + +const MAX_ADDITIONAL_FIELDS_LENGTH = 10; + +export const validateJSON = (...args: Parameters): ReturnType => { + const [{ value }] = args; + + try { + if (typeof value === 'string' && !isEmpty(value)) { + const parsedJSON = JSON.parse(value); + + if (!isObject(parsedJSON)) { + return { + code: 'ERR_JSON_FORMAT', + message: i18n.INVALID_JSON_FORMAT, + }; + } + + if (Object.keys(parsedJSON).length > MAX_ADDITIONAL_FIELDS_LENGTH) { + return { + code: 'ERR_JSON_FORMAT', + message: i18n.MAX_ATTRIBUTES_ERROR(MAX_ADDITIONAL_FIELDS_LENGTH), + }; + } + } + } catch (error) { + return { + code: 'ERR_JSON_FORMAT', + message: i18n.INVALID_JSON_FORMAT, + }; + } +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/form_context.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/form_context.test.tsx index 252726ef559c9..de3f5223fd16d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/form_context.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/form_context.test.tsx @@ -618,6 +618,7 @@ describe('Create case', () => { urgency: null, category: null, subcategory: null, + additionalFields: null, }, id: 'servicenow-1', name: 'My SN connector', @@ -818,7 +819,7 @@ describe('Create case', () => { }); await user.selectOptions(screen.getByTestId('severitySelect'), '4 - Low'); - expect(screen.getByTestId('severitySelect')).toHaveValue('4'); + expect(await screen.findByTestId('severitySelect')).toHaveValue('4'); await user.click(screen.getByTestId('dropdown-connectors')); await user.click(screen.getByTestId('dropdown-connector-servicenow-2')); @@ -836,6 +837,7 @@ describe('Create case', () => { impact: null, severity: null, urgency: null, + additionalFields: null, }, id: 'servicenow-2', name: 'My SN connector 2', diff --git a/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.test.tsx index 04604ecfa60b3..69aabef03d7c4 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.test.tsx @@ -44,6 +44,7 @@ describe('ConnectorsForm ', () => { impact: '2', category: 'Denial of Service', subcategory: '12', + additionalFields: '{}', }, }, 'resilient-2': { @@ -90,17 +91,13 @@ describe('ConnectorsForm ', () => { it('sets the selected connector correctly', async () => { appMockRender.render(); - await waitFor(() => { - expect(screen.getByText('My SN connector')).toBeInTheDocument(); - }); + expect(screen.getByText('My SN connector')).toBeInTheDocument(); }); it('sets the fields for the selected connector correctly', async () => { appMockRender.render(); - await waitFor(() => { - expect(screen.getByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); - }); + expect(screen.getByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); const severitySelect = screen.getByTestId('severitySelect'); const urgencySelect = screen.getByTestId('urgencySelect'); @@ -163,6 +160,7 @@ describe('ConnectorsForm ', () => { impact: '2', category: 'Denial of Service', subcategory: '12', + additionalFields: '{}', }, }); }); @@ -367,17 +365,13 @@ describe('ConnectorsForm ', () => { /> ); - await waitFor(() => { - expect(screen.getByText('My SN connector')).toBeInTheDocument(); - }); + expect(await screen.findByText('My SN connector')).toBeInTheDocument(); await userEvent.click(screen.getByTestId('dropdown-connectors')); await waitForEuiPopoverOpen(); await userEvent.click(screen.getByTestId('dropdown-connector-servicenow-2')); - await waitFor(() => { - expect(screen.getByText('My SN connector 2')).toBeInTheDocument(); - }); + expect(await screen.findByText('My SN connector 2')).toBeInTheDocument(); await userEvent.click(screen.getByTestId('edit-connectors-submit')); @@ -389,6 +383,7 @@ describe('ConnectorsForm ', () => { impact: null, severity: null, urgency: null, + additionalFields: null, }, id: 'servicenow-2', name: 'My SN connector 2', diff --git a/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.tsx b/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.tsx index 2cbe8c185061b..04ed2d8520919 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/edit_connector/connectors_form.tsx @@ -13,7 +13,7 @@ import { useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import React, { useCallback, useMemo } from 'react'; -import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { NONE_CONNECTOR_ID } from '../../../common/constants'; import type { CaseConnectors, CaseUI } from '../../../common/ui/types'; import { ConnectorFieldsForm } from '../connectors/fields_form'; @@ -141,6 +141,7 @@ const ConnectorsFormComponent: React.FC = ({ + diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts index c6c9f1063df60..f8e07522c46b8 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts @@ -141,6 +141,7 @@ describe('utils', () => { expect(res).toEqual({ incident: { + additional_fields: null, category: null, subcategory: null, correlation_display: 'Elastic Case', @@ -174,6 +175,7 @@ describe('utils', () => { expect(res).toEqual({ incident: { + additional_fields: null, category: null, subcategory: null, correlation_display: 'Elastic Case', diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.test.ts b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.test.ts index 3841d15f8aa90..fedaae941b703 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.test.ts @@ -12,14 +12,27 @@ describe('ITSM formatter', () => { const theCase = { id: 'case-id', connector: { - fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' }, + fields: { + severity: '2', + urgency: '2', + impact: '2', + category: 'software', + subcategory: 'os', + additionalFields: '{}', + }, }, } as Case; it('it formats correctly', async () => { const res = await format(theCase, []); + expect(res).toEqual({ - ...theCase.connector.fields, + severity: '2', + urgency: '2', + impact: '2', + category: 'software', + subcategory: 'os', + additional_fields: '{}', correlation_display: 'Elastic Case', correlation_id: 'case-id', }); @@ -29,6 +42,7 @@ describe('ITSM formatter', () => { const invalidFields = { connector: { fields: null } } as Case; const res = await format(invalidFields, []); expect(res).toEqual({ + additional_fields: null, severity: null, urgency: null, impact: null, diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.ts b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.ts index 2063bd49e1eff..dae920d0f09ca 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/itsm_format.ts @@ -15,6 +15,7 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => { impact = null, category = null, subcategory = null, + additionalFields = null, } = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; return { severity, @@ -22,6 +23,7 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => { impact, category, subcategory, + additional_fields: additionalFields, correlation_id: theCase.id ?? null, correlation_display: 'Elastic Case', }; diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.test.ts b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.test.ts index fec26c9b032f2..268ffd2873c73 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.test.ts @@ -20,6 +20,7 @@ describe('SIR formatter', () => { malwareHash: true, malwareUrl: true, priority: '2 - High', + additionalFields: '{"foo": "bar"}', }, }, } as Case; @@ -36,6 +37,7 @@ describe('SIR formatter', () => { priority: '2 - High', correlation_display: 'Elastic Case', correlation_id: 'case-id', + additional_fields: '{"foo": "bar"}', }); }); @@ -52,6 +54,7 @@ describe('SIR formatter', () => { priority: null, correlation_display: 'Elastic Case', correlation_id: null, + additional_fields: null, }); }); @@ -92,6 +95,7 @@ describe('SIR formatter', () => { priority: '2 - High', correlation_display: 'Elastic Case', correlation_id: 'case-id', + additional_fields: '{"foo": "bar"}', }); }); @@ -129,6 +133,7 @@ describe('SIR formatter', () => { priority: '2 - High', correlation_display: 'Elastic Case', correlation_id: 'case-id', + additional_fields: '{"foo": "bar"}', }); }); @@ -172,6 +177,7 @@ describe('SIR formatter', () => { priority: '2 - High', correlation_display: 'Elastic Case', correlation_id: 'case-id', + additional_fields: '{"foo": "bar"}', }); }); }); diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.ts b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.ts index 810a9a11d0e54..259ddbe46b10f 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/sir_format.ts @@ -17,6 +17,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { malwareHash = null, malwareUrl = null, priority = null, + additionalFields = null, } = (theCase.connector.fields as ConnectorServiceNowSIRTypeFields['fields']) ?? {}; const alertFieldMapping: AlertFieldMappingAndValues = { destIp: { alertPath: 'destination.ip', sirFieldKey: 'dest_ip', add: !!destIp }, @@ -72,6 +73,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { category, subcategory, priority, + additional_fields: additionalFields, correlation_id: theCase.id ?? null, correlation_display: 'Elastic Case', }; diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/types.ts b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/types.ts index 7e0c0330c2dea..942ba5591d8f3 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/servicenow/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { ServiceNowITSMFieldsType } from '../../../common/types/domain'; import type { ICasesConnector } from '../types'; interface CorrelationValues { @@ -13,6 +12,7 @@ interface CorrelationValues { correlation_display: string | null; } +// ServiceNow SIR export interface ServiceNowSIRFieldsType extends CorrelationValues { dest_ip: string[] | null; source_ip: string[] | null; @@ -21,6 +21,7 @@ export interface ServiceNowSIRFieldsType extends CorrelationValues { malware_hash: string[] | null; malware_url: string[] | null; priority: string | null; + additional_fields: string | null; } export type SirFieldKey = 'dest_ip' | 'source_ip' | 'malware_hash' | 'malware_url'; @@ -30,11 +31,19 @@ export type AlertFieldMappingAndValues = Record< >; // ServiceNow ITSM -export type ServiceNowITSMCasesConnector = ICasesConnector; -export type ServiceNowITSMFormat = ICasesConnector< - ServiceNowITSMFieldsType & CorrelationValues ->['format']; -export type ServiceNowITSMGetMapping = ICasesConnector['getMapping']; +export interface ServiceNowITSMFieldsTypeConnector extends CorrelationValues { + impact: string | null; + severity: string | null; + urgency: string | null; + category: string | null; + subcategory: string | null; + additional_fields: string | null; +} + +export type ServiceNowITSMCasesConnector = ICasesConnector; +export type ServiceNowITSMFormat = ICasesConnector['format']; +export type ServiceNowITSMGetMapping = + ICasesConnector['getMapping']; // ServiceNow SIR export type ServiceNowSIRCasesConnector = ICasesConnector; diff --git a/x-pack/platform/plugins/shared/cases/tsconfig.json b/x-pack/platform/plugins/shared/cases/tsconfig.json index 9a334a1df1828..5368aa8418ef3 100644 --- a/x-pack/platform/plugins/shared/cases/tsconfig.json +++ b/x-pack/platform/plugins/shared/cases/tsconfig.json @@ -79,6 +79,9 @@ "@kbn/cloud-plugin", "@kbn/core-http-server-mocks", "@kbn/core-http-server-utils", + "@kbn/code-editor-mock", + "@kbn/monaco", + "@kbn/code-editor", ], "exclude": [ "target/**/*",