[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:

<img width="901" alt="Screenshot 2024-11-27 at 12 52 37"
src="https://github.com/user-attachments/assets/6734a50b-b413-4587-b5e2-2caf2e30ad67">

**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:

<img width="1378" alt="Screenshot 2024-11-27 at 13 46 56"
src="https://github.com/user-attachments/assets/8064f882-2ab5-4fd6-b123-90938ab3bb83">

## 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>
This commit is contained in:
Antonio 2025-01-02 14:13:47 +01:00 committed by GitHub
parent 4a32b502d4
commit d209afda4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 576 additions and 63 deletions

View file

@ -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<typeof ServiceNowITSMFieldsRt>;
@ -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<typeof ServiceNowSIRFieldsRt>;

View file

@ -682,6 +682,7 @@ describe('CommonFlyout ', () => {
impact: null,
category: 'software',
subcategory: null,
additionalFields: null,
},
},
settings: {

View file

@ -74,4 +74,32 @@ describe('ConnectorCard ', () => {
expect(getByText(`${item.title}: ${item.description}`)).toBeInTheDocument();
}
});
it('shows a codeblock when applicable', async () => {
render(
<ConnectorCard
connectorType={ConnectorTypes.none}
title="My connector"
listItems={[{ title: 'some title', description: 'some code', displayAsCodeBlock: true }]}
isLoading={false}
/>
);
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(
<ConnectorCard
connectorType={ConnectorTypes.none}
title="My connector"
listItems={[{ title: 'some title', description: 'some code' }]}
isLoading={false}
/>
);
expect(await screen.findByTestId('card-list-item')).toBeInTheDocument();
expect(screen.queryByTestId('card-list-code-block')).not.toBeInTheDocument();
});
});

View file

@ -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<ConnectorCardProps> = ({
</EuiFlexGroup>
<EuiFlexItem data-test-subj="connector-card-details">
{listItems.length > 0 &&
listItems.map((item, i) => (
<EuiText size="xs" data-test-subj="card-list-item" key={`${item.title}-${i}`}>
<strong>{`${item.title}: `}</strong>
{`${item.description}`}
</EuiText>
))}
listItems.map((item, i) =>
item.displayAsCodeBlock ? (
<>
<EuiText size="xs" data-test-subj="card-list-item" key={`${item.title}-${i}`}>
<strong>{`${item.title}:`}</strong>
</EuiText>
<EuiCodeBlock
data-test-subj="card-list-code-block"
language="json"
fontSize="s"
paddingSize="s"
>
{`${item.description}`}
</EuiCodeBlock>
</>
) : (
<EuiText size="xs" data-test-subj="card-list-item" key={`${item.title}-${i}`}>
<strong>{`${item.title}: `}</strong>
{`${item.description}`}
</EuiText>
)
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiSkeletonText>

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
* 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<typeof MockedMonacoEditor>) => (
<MockedCodeEditor {...props} />
),
};
});
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<unknown, string>,
paramsProperty: 'myField',
label: 'label',
dataTestSubj: 'foobarTestSubj',
};
beforeEach(() => jest.resetAllMocks());
it('renders as expected', async () => {
render(<JsonEditorField {...props} />);
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(<JsonEditorField {...props} />);
await userEvent.click(await screen.findByTestId('myFieldJsonEditor'));
await userEvent.paste('JSON');
await waitFor(() => {
expect(setValue).toBeCalledWith('foobarJSON');
});
expect(setXJson).toBeCalledWith('foobarJSON');
});
});

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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<unknown, string>;
paramsProperty: string;
ariaLabel?: string;
onBlur?: () => void;
dataTestSubj?: string;
euiCodeEditorProps?: { [key: string]: unknown };
}
const { useXJsonMode } = XJson;
export const JsonEditorField: React.FunctionComponent<Props> = ({
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 (
<EuiFormRow
data-test-subj={dataTestSubj}
fullWidth
error={errors}
isInvalid={errors && errors.length > 0 && inputTargetValue !== undefined}
label={label}
helpText={helpText}
>
<CodeEditor
languageId={XJsonLang.ID}
options={{
renderValidationDecorations: xJson ? 'on' : 'off', // Disable error underline when empty
lineNumbers: 'on',
fontSize: 14,
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
folding: true,
wordWrap: 'on',
wrappingIndent: 'indent',
automaticLayout: true,
}}
value={xJson}
width="100%"
height="200px"
data-test-subj={`${paramsProperty}JsonEditor`}
aria-label={ariaLabel}
{...euiCodeEditorProps}
onChange={(xjson: string) => {
setXJson(xjson);
// Keep the documents in sync with the editor content
onDocumentsChange(convertToJson(xjson));
}}
/>
</EuiFormRow>
);
};
JsonEditorField.displayName = 'JsonEditorField';

View file

@ -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', () => {
</MockFormWrapperComponent>
);
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', () => {
</MockFormWrapperComponent>
);
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 () => {

View file

@ -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<ConnectorFieldsProp
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="fields.additionalFields"
component={JsonEditorField}
config={{
label: i18n.ADDITIONAL_FIELDS_LABEL,
validations: [
{
validator: validateJSON,
},
],
}}
componentProps={{
euiCodeEditorProps: {
fullWidth: true,
height: '200px',
options: {
fontSize: '12px',
renderValidationDecorations: 'off',
},
},
dataTestSubj: 'additionalFieldsEditor',
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</>
);

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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 () => {

View file

@ -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<ConnectorFieldsProps
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="fields.additionalFields"
component={JsonEditorField}
config={{
label: i18n.ADDITIONAL_FIELDS_LABEL,
validations: [
{
validator: validateJSON,
},
],
}}
componentProps={{
euiCodeEditorProps: {
fullWidth: true,
height: '200px',
options: {
fontSize: '12px',
renderValidationDecorations: 'off',
},
},
dataTestSubj: 'additionalFieldsEditor',
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</>
);

View file

@ -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();
});
});

View file

@ -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,
]
);

View file

@ -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.',
});

View file

@ -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<FormData, unknown>;
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.',
});
});
});

View file

@ -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<ValidationFunc>): ReturnType<ValidationFunc> => {
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,
};
}
};

View file

@ -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',

View file

@ -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(<ConnectorsForm {...props} />);
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(<ConnectorsForm {...props} />);
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',

View file

@ -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<Props> = ({
<EuiFlexItem data-test-subj="edit-connector-fields-form-flex-item">
<ConnectorFieldsForm connector={currentActionConnector} key={connectorId} />
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>

View file

@ -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',

View file

@ -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,

View file

@ -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',
};

View file

@ -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"}',
});
});
});

View file

@ -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',
};

View file

@ -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<ServiceNowITSMFieldsType>;
export type ServiceNowITSMFormat = ICasesConnector<
ServiceNowITSMFieldsType & CorrelationValues
>['format'];
export type ServiceNowITSMGetMapping = ICasesConnector<ServiceNowITSMFieldsType>['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<ServiceNowITSMFieldsTypeConnector>;
export type ServiceNowITSMFormat = ICasesConnector<ServiceNowITSMFieldsTypeConnector>['format'];
export type ServiceNowITSMGetMapping =
ICasesConnector<ServiceNowITSMFieldsTypeConnector>['getMapping'];
// ServiceNow SIR
export type ServiceNowSIRCasesConnector = ICasesConnector<ServiceNowSIRFieldsType>;

View file

@ -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/**/*",