mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
4a32b502d4
commit
d209afda4c
27 changed files with 576 additions and 63 deletions
|
@ -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>;
|
||||
|
||||
|
|
|
@ -682,6 +682,7 @@ describe('CommonFlyout ', () => {
|
|||
impact: null,
|
||||
category: 'software',
|
||||
subcategory: null,
|
||||
additionalFields: null,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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.',
|
||||
});
|
||||
|
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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"}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue