[Security Solution][Case] ServiceNow ITSM: Add category & subcategory fields (#90547)

This commit is contained in:
Christos Nasikas 2021-02-11 13:08:39 +02:00 committed by GitHub
parent 01b3d07590
commit a9f2c91673
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 666 additions and 129 deletions

View file

@ -595,6 +595,8 @@ The following table describes the properties of the `incident` object.
| severity | The name of the severity in ServiceNow. | string _(optional)_ |
| urgency | The name of the urgency in ServiceNow. | string _(optional)_ |
| impact | The name of the impact in ServiceNow. | string _(optional)_ |
| category | The name of the category in ServiceNow. | string _(optional)_ |
| subcategory | The name of the subcategory in ServiceNow. | string _(optional)_ |
#### `subActionParams (getFields)`

View file

@ -88,6 +88,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
caller_id: 'elastic',
description: 'Incident description',
short_description: 'Incident title',
@ -111,6 +113,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
@ -123,6 +127,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
comments: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
@ -146,6 +152,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
@ -158,6 +166,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
work_notes: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
@ -229,6 +239,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
},
@ -251,6 +263,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
},
@ -262,6 +276,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
@ -285,6 +301,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
},
@ -296,6 +314,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',

View file

@ -112,6 +112,8 @@ const executorParams: ExecutorSubActionPushParams = {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
},
comments: [
{

View file

@ -45,6 +45,8 @@ const CommonAttributes = {
short_description: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
category: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
};
// Schema for ServiceNow Incident Management (ITSM)
@ -62,13 +64,11 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({
export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
incident: schema.object({
...CommonAttributes,
category: schema.nullable(schema.string()),
dest_ip: schema.nullable(schema.string()),
malware_hash: schema.nullable(schema.string()),
malware_url: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
source_ip: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
}),
comments: CommentsSchema,
});

View file

@ -7,6 +7,7 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts
export const JiraFieldsRT = rt.type({
issueType: rt.union([rt.string, rt.null]),
priority: rt.union([rt.string, rt.null]),

View file

@ -7,6 +7,7 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts
export const ResilientFieldsRT = rt.type({
incidentTypes: rt.union([rt.array(rt.string), rt.null]),
severityCode: rt.union([rt.string, rt.null]),

View file

@ -7,10 +7,13 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts
export const ServiceNowITSMFieldsRT = rt.type({
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 type ServiceNowITSMFieldsType = rt.TypeOf<typeof ServiceNowITSMFieldsRT>;

View file

@ -7,6 +7,7 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts
export const ServiceNowSIRFieldsRT = rt.type({
category: rt.union([rt.string, rt.null]),
destIp: rt.union([rt.boolean, rt.null]),

View file

@ -153,6 +153,8 @@ describe('case connector', () => {
impact: 'Medium',
severity: 'Medium',
urgency: 'Medium',
category: 'software',
subcategory: 'os',
},
},
settings: {
@ -218,7 +220,13 @@ describe('case connector', () => {
id: 'servicenow',
name: 'Servicenow',
type: '.servicenow',
fields: { impact: null, severity: null, urgency: null },
fields: {
impact: null,
severity: null,
urgency: null,
category: null,
subcategory: null,
},
},
settings: {
syncAlerts: true,
@ -293,6 +301,8 @@ describe('case connector', () => {
impact: 'Medium',
severity: 'Medium',
urgency: 'Medium',
category: 'software',
subcategory: 'os',
excess: null,
},
},
@ -470,6 +480,8 @@ describe('case connector', () => {
impact: 'Medium',
severity: 'Medium',
urgency: 'Medium',
category: 'software',
subcategory: 'os',
},
},
},
@ -517,7 +529,13 @@ describe('case connector', () => {
id: 'servicenow',
name: 'Servicenow',
type: '.servicenow',
fields: { impact: null, severity: null, urgency: null },
fields: {
impact: null,
severity: null,
urgency: null,
category: null,
subcategory: null,
},
},
},
});
@ -590,6 +608,8 @@ describe('case connector', () => {
impact: 'Medium',
severity: 'Medium',
urgency: 'Medium',
category: 'software',
subcategory: 'os',
excess: null,
},
},

View file

@ -53,6 +53,8 @@ const ServiceNowFieldsSchema = schema.object({
impact: schema.nullable(schema.string()),
severity: schema.nullable(schema.string()),
urgency: schema.nullable(schema.string()),
category: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
});
const NoneFieldsSchema = schema.nullable(schema.object({}));

View file

@ -9,9 +9,9 @@ import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../
import { ExternalServiceFormatter } from '../types';
const format: ExternalServiceFormatter<ServiceNowITSMFieldsType>['format'] = (theCase) => {
const { severity = null, urgency = null, impact = null } =
const { severity = null, urgency = null, impact = null, category = null, subcategory = null } =
(theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {};
return { severity, urgency, impact };
return { severity, urgency, impact, category, subcategory };
};
export const serviceNowITSMExternalServiceFormatter: ExternalServiceFormatter<ServiceNowITSMFieldsType> = {

View file

@ -10,7 +10,9 @@ import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter';
describe('ITSM formatter', () => {
const theCase = {
connector: { fields: { severity: '2', urgency: '2', impact: '2' } },
connector: {
fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' },
},
} as CaseResponse;
it('it formats correctly', async () => {
@ -21,6 +23,12 @@ describe('ITSM formatter', () => {
it('it formats correctly when fields do not exist ', async () => {
const invalidFields = { connector: { fields: null } } as CaseResponse;
const res = await serviceNowITSMExternalServiceFormatter.format(invalidFields, []);
expect(res).toEqual({ severity: null, urgency: null, impact: null });
expect(res).toEqual({
severity: null,
urgency: null,
impact: null,
category: null,
subcategory: null,
});
});
});

View file

@ -153,6 +153,18 @@ export const executeResponses = {
value: 'inbound_ddos',
element: 'subcategory',
},
{
dependent_value: '',
label: 'Software',
value: 'software',
element: 'category',
},
{
dependent_value: 'software',
label: 'Operation System',
value: 'os',
element: 'subcategory',
},
...['severity', 'urgency', 'impact', 'priority']
.map((element) => [
{

View file

@ -92,6 +92,6 @@ export const fillIbmResilientConnectorOptions = (
ibmResilientConnector.incidentTypes.forEach((incidentType) => {
cy.get(SELECT_INCIDENT_TYPE).type(`${incidentType}{enter}`, { force: true });
});
cy.get(CONNECTOR_RESILIENT).click();
cy.get(CONNECTOR_RESILIENT).click({ force: true });
cy.get(SELECT_SEVERITY).select(ibmResilientConnector.severity);
};

View file

@ -58,6 +58,18 @@ export const choices = [
value: 'inbound_ddos',
element: 'subcategory',
},
{
dependent_value: '',
label: 'Software',
value: 'software',
element: 'category',
},
{
dependent_value: 'software',
label: 'Operation System',
value: 'os',
element: 'subcategory',
},
...['severity', 'urgency', 'impact', 'priority']
.map((element) => [
{

View file

@ -0,0 +1,12 @@
/*
* 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 { EuiSelectOption } from '@elastic/eui';
import { Choice } from './types';
export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));

View file

@ -20,12 +20,18 @@ jest.mock('../../../../common/lib/kibana');
jest.mock('./use_get_choices', () => ({
useGetChoices: (args: { onSuccess: () => void }) => {
onChoicesSuccess = args.onSuccess;
return { isLoading: false, mockChoices };
return { isLoading: false, choices: mockChoices };
},
}));
describe('ServiceNowITSM Fields', () => {
const fields = { severity: '1', urgency: '2', impact: '3' };
const fields = {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
};
const onChange = jest.fn();
beforeEach(() => {
@ -37,6 +43,8 @@ describe('ServiceNowITSM Fields', () => {
expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy();
});
it('all params fields are rendered - isEdit: false', () => {
@ -58,6 +66,42 @@ describe('ServiceNowITSM Fields', () => {
);
});
test('it transforms the categories to options correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
act(() => {
onChoicesSuccess(mockChoices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([
{ value: 'Priviledge Escalation', text: 'Priviledge Escalation' },
{
value: 'Criminal activity/investigation',
text: 'Criminal activity/investigation',
},
{ value: 'Denial of Service', text: 'Denial of Service' },
{
value: 'software',
text: 'Software',
},
]);
});
test('it transforms the subcategories to options correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
act(() => {
onChoicesSuccess(mockChoices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([
{
text: 'Operation System',
value: 'os',
},
]);
});
it('it transforms the options correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
act(() => {
@ -81,7 +125,7 @@ describe('ServiceNowITSM Fields', () => {
expect(onChange).toHaveBeenCalledWith(fields);
const testers = ['severity', 'urgency', 'impact'];
const testers = ['severity', 'urgency', 'impact', 'subcategory'];
testers.forEach((subj) =>
test(`${subj.toUpperCase()}`, async () => {
await waitFor(() => {
@ -99,5 +143,22 @@ describe('ServiceNowITSM Fields', () => {
});
})
);
test('it should set subcategory to null when changing category', async () => {
await waitFor(() => {
const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!;
select.prop('onChange')!({
target: {
value: 'network',
},
} as React.ChangeEvent<HTMLSelectElement>);
});
wrapper.update();
expect(onChange).toHaveBeenCalledWith({
...fields,
subcategory: null,
category: 'network',
});
});
});
});

View file

@ -17,22 +17,39 @@ import {
import { useKibana } from '../../../../common/lib/kibana';
import { ConnectorCard } from '../card';
import { useGetChoices } from './use_get_choices';
import { Options, Choice } from './types';
import { Fields, Choice } from './types';
import { choicesToEuiOptions } from './helpers';
const useGetChoicesFields = ['urgency', 'severity', 'impact'];
const defaultOptions: Options = {
const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory'];
const defaultFields: Fields = {
urgency: [],
severity: [],
impact: [],
category: [],
subcategory: [],
};
const ServiceNowITSMFieldsComponent: React.FunctionComponent<
ConnectorFieldsProps<ServiceNowITSMFieldsType>
> = ({ isEdit = true, fields, connector, onChange }) => {
const init = useRef(true);
const { severity = null, urgency = null, impact = null } = fields ?? {};
const { severity = null, urgency = null, impact = null, category = null, subcategory = null } =
fields ?? {};
const { http, notifications } = useKibana().services;
const [options, setOptions] = useState<Options>(defaultOptions);
const [choices, setChoices] = useState<Fields>(defaultFields);
const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]);
const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]);
const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]);
const subcategoryOptions = useMemo(
() =>
choicesToEuiOptions(
choices.subcategory.filter((choice) => choice.dependent_value === category)
),
[choices.subcategory, category]
);
const listItems = useMemo(
() => [
@ -40,7 +57,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
? [
{
title: i18n.URGENCY,
description: options.urgency.find((option) => `${option.value}` === urgency)?.text,
description: urgencyOptions.find((option) => `${option.value}` === urgency)?.text,
},
]
: []),
@ -48,7 +65,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
? [
{
title: i18n.SEVERITY,
description: options.severity.find((option) => `${option.value}` === severity)?.text,
description: severityOptions.find((option) => `${option.value}` === severity)?.text,
},
]
: []),
@ -56,27 +73,53 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
? [
{
title: i18n.IMPACT,
description: options.impact.find((option) => `${option.value}` === impact)?.text,
description: impactOptions.find((option) => `${option.value}` === impact)?.text,
},
]
: []),
...(category != null && category.length > 0
? [
{
title: i18n.CATEGORY,
description: categoryOptions.find((option) => `${option.value}` === category)?.text,
},
]
: []),
...(subcategory != null && subcategory.length > 0
? [
{
title: i18n.SUBCATEGORY,
description: subcategoryOptions.find((option) => `${option.value}` === subcategory)
?.text,
},
]
: []),
],
[urgency, options.urgency, options.severity, options.impact, severity, impact]
[
category,
categoryOptions,
impact,
impactOptions,
severity,
severityOptions,
subcategory,
subcategoryOptions,
urgency,
urgencyOptions,
]
);
const onChoicesSuccess = (choices: Choice[]) =>
setOptions(
choices.reduce(
(acc, choice) => ({
const onChoicesSuccess = (values: Choice[]) => {
setChoices(
values.reduce(
(acc, value) => ({
...acc,
[choice.element]: [
...(acc[choice.element] != null ? acc[choice.element] : []),
{ value: choice.value, text: choice.label },
],
[value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value],
}),
defaultOptions
defaultFields
)
);
};
const { isLoading: isLoadingChoices } = useGetChoices({
http,
@ -100,17 +143,17 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
useEffect(() => {
if (init.current) {
init.current = false;
onChange({ urgency, severity, impact });
onChange({ urgency, severity, impact, category, subcategory });
}
}, [impact, onChange, severity, urgency]);
}, [category, impact, onChange, severity, subcategory, urgency]);
return isEdit ? (
<div data-test-subj={'connector-fields-sn'}>
<div data-test-subj={'connector-fields-sn-itsm'}>
<EuiFormRow fullWidth label={i18n.URGENCY}>
<EuiSelect
fullWidth
data-test-subj="urgencySelect"
options={options.urgency}
options={urgencyOptions}
value={urgency ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
@ -125,7 +168,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
<EuiSelect
fullWidth
data-test-subj="severitySelect"
options={options.severity}
options={severityOptions}
value={severity ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
@ -139,7 +182,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
<EuiSelect
fullWidth
data-test-subj="impactSelect"
options={options.impact}
options={impactOptions}
value={impact ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
@ -149,6 +192,37 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
options={categoryOptions}
value={category ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={subcategory ?? ''}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
) : (
<ConnectorCard

View file

@ -97,6 +97,10 @@ describe('ServiceNowSIR Fields', () => {
text: 'Criminal activity/investigation',
},
{ value: 'Denial of Service', text: 'Denial of Service' },
{
text: 'Software',
value: 'software',
},
]);
});
@ -176,7 +180,7 @@ describe('ServiceNowSIR Fields', () => {
})
);
const testers = ['priority', 'category', 'subcategory'];
const testers = ['priority', 'subcategory'];
testers.forEach((subj) =>
test(`${subj.toUpperCase()}`, async () => {
await waitFor(() => {
@ -194,5 +198,24 @@ describe('ServiceNowSIR Fields', () => {
});
})
);
test('it should set subcategory to null when changing category', async () => {
const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!;
select.prop('onChange')!({
target: {
value: 'network',
},
} as React.ChangeEvent<HTMLSelectElement>);
wrapper.update();
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith({
...fields,
subcategory: null,
category: 'network',
});
});
});
});
});

View file

@ -6,14 +6,7 @@
*/
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
EuiFormRow,
EuiSelect,
EuiFlexGroup,
EuiFlexItem,
EuiSelectOption,
EuiCheckbox,
} from '@elastic/eui';
import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiCheckbox } from '@elastic/eui';
import {
ConnectorTypes,
@ -24,6 +17,7 @@ import { ConnectorFieldsProps } from '../types';
import { ConnectorCard } from '../card';
import { useGetChoices } from './use_get_choices';
import { Choice, Fields } from './types';
import { choicesToEuiOptions } from './helpers';
import * as i18n from './translations';
@ -34,9 +28,6 @@ const defaultFields: Fields = {
priority: [],
};
const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));
const ServiceNowSIRFieldsComponent: React.FunctionComponent<
ConnectorFieldsProps<ServiceNowSIRFieldsType>
> = ({ isEdit = true, fields, connector, onChange }) => {
@ -179,7 +170,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
}, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]);
return isEdit ? (
<div data-test-subj={'connector-fields-sn'}>
<div data-test-subj={'connector-fields-sn-sir'}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.ALERT_FIELDS_LABEL}>
@ -259,7 +250,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('category', e.target.value)}
onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })}
/>
</EuiFormRow>
</EuiFlexItem>
@ -269,7 +260,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
fullWidth
data-test-subj="subcategorySelect"
options={subcategoryOptions}
value={subcategory ?? undefined}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={subcategory ?? ''}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import { EuiSelectOption } from '@elastic/eui';
export interface Choice {
value: string;
label: string;
@ -15,4 +13,3 @@ export interface Choice {
}
export type Fields = Record<string, Choice[]>;
export type Options = Record<string, EuiSelectOption[]>;

View file

@ -96,10 +96,10 @@ describe('Connector', () => {
);
});
// await waitFor(() => {
// wrapper.update();
// expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy();
// });
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy();
});
});
it('it is loading when fetching connectors', async () => {

View file

@ -20,6 +20,7 @@ import { connectorsMock } from '../../containers/configure/mock';
import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types';
import { useGetSeverity } from '../connectors/resilient/use_get_severity';
import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types';
import { useGetChoices } from '../connectors/servicenow/use_get_choices';
import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
import {
@ -30,6 +31,7 @@ import {
useGetSeverityResponse,
useGetIssueTypesResponse,
useGetFieldsByIssueTypeResponse,
useGetChoicesResponse,
} from './mock';
import { FormContext } from './form_context';
import { CreateCaseForm } from './form';
@ -49,6 +51,7 @@ jest.mock('../connectors/jira/use_get_issue_types');
jest.mock('../connectors/jira/use_get_fields_by_issue_type');
jest.mock('../connectors/jira/use_get_single_issue');
jest.mock('../connectors/jira/use_get_issues');
jest.mock('../connectors/servicenow/use_get_choices');
const useConnectorsMock = useConnectors as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
@ -58,6 +61,7 @@ const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
const useGetIssueTypesMock = useGetIssueTypes as jest.Mock;
const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock;
const useGetChoicesMock = useGetChoices as jest.Mock;
const postCase = jest.fn();
const pushCaseToExternalService = jest.fn();
@ -109,6 +113,7 @@ describe('Create case', () => {
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse);
useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse);
useGetChoicesMock.mockReturnValue(useGetChoicesResponse);
(useGetTags as jest.Mock).mockImplementation(() => ({
tags: sampleTags,
@ -219,6 +224,8 @@ describe('Create case', () => {
impact: null,
severity: null,
urgency: null,
category: null,
subcategory: null,
},
id: 'servicenow-1',
name: 'My Connector',
@ -399,7 +406,7 @@ describe('Create case', () => {
});
});
it(`it should submit and push to servicenow connector`, async () => {
it(`it should submit and push to servicenow itsm connector`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
@ -415,10 +422,14 @@ describe('Create case', () => {
);
fillForm(wrapper);
expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeFalsy();
expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click');
expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy();
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy();
});
['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => {
wrapper
@ -429,6 +440,20 @@ describe('Create case', () => {
});
});
wrapper
.find('select[data-test-subj="categorySelect"]')
.first()
.simulate('change', {
target: { value: 'software' },
});
wrapper
.find('select[data-test-subj="subcategorySelect"]')
.first()
.simulate('change', {
target: { value: 'os' },
});
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
await waitFor(() => {
@ -438,7 +463,13 @@ describe('Create case', () => {
id: 'servicenow-1',
name: 'My Connector',
type: '.servicenow',
fields: { impact: '2', severity: '2', urgency: '2' },
fields: {
impact: '2',
severity: '2',
urgency: '2',
category: 'software',
subcategory: 'os',
},
},
});
@ -448,7 +479,110 @@ describe('Create case', () => {
id: 'servicenow-1',
name: 'My Connector',
type: '.servicenow',
fields: { impact: '2', severity: '2', urgency: '2' },
fields: {
impact: '2',
severity: '2',
urgency: '2',
category: 'software',
subcategory: 'os',
},
},
});
expect(onFormSubmitSuccess).toHaveBeenCalledWith({
id: sampleId,
...sampleData,
});
});
});
it(`it should submit and push to servicenow sir connector`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm />
<SubmitCaseButton />
</FormContext>
</TestProviders>
);
fillForm(wrapper);
expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-sir"]`).simulate('click');
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeTruthy();
});
wrapper
.find('[data-test-subj="destIpCheckbox"] input')
.first()
.simulate('change', { target: { checked: false } });
wrapper
.find('select[data-test-subj="prioritySelect"]')
.first()
.simulate('change', {
target: { value: '1' },
});
wrapper
.find('select[data-test-subj="categorySelect"]')
.first()
.simulate('change', {
target: { value: 'Denial of Service' },
});
wrapper
.find('select[data-test-subj="subcategorySelect"]')
.first()
.simulate('change', {
target: { value: '26' },
});
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
await waitFor(() => {
expect(postCase).toBeCalledWith({
...sampleData,
connector: {
id: 'servicenow-sir',
name: 'My Connector SIR',
type: '.servicenow-sir',
fields: {
destIp: false,
sourceIp: true,
malwareHash: true,
malwareUrl: true,
priority: '1',
category: 'Denial of Service',
subcategory: '26',
},
},
});
expect(pushCaseToExternalService).toHaveBeenCalledWith({
caseId: sampleId,
connector: {
id: 'servicenow-sir',
name: 'My Connector SIR',
type: '.servicenow-sir',
fields: {
destIp: false,
sourceIp: true,
malwareHash: true,
malwareUrl: true,
priority: '1',
category: 'Denial of Service',
subcategory: '26',
},
},
});

View file

@ -7,6 +7,7 @@
import { CasePostRequest } from '../../../../../case/common/api';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
import { choices } from '../connectors/mock';
export const sampleTags = ['coke', 'pepsi'];
export const sampleData: CasePostRequest = {
@ -93,3 +94,8 @@ export const useGetFieldsByIssueTypeResponse = {
},
},
};
export const useGetChoicesResponse = {
isLoading: false,
choices,
};

View file

@ -61,6 +61,15 @@ export const connectorsMock: ActionConnector[] = [
},
isPreconfigured: false,
},
{
id: 'servicenow-sir',
actionTypeId: '.servicenow-sir',
name: 'My Connector SIR',
config: {
apiUrl: 'https://instance1.service-now.com',
},
isPreconfigured: false,
},
];
export const actionTypesMock: ActionTypeConnector[] = [

View file

@ -0,0 +1,12 @@
/*
* 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 { EuiSelectOption } from '@elastic/eui';
import { Choice } from './types';
export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));

View file

@ -28,6 +28,8 @@ const actionParams = {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
externalId: null,
},
comments: [],
@ -55,34 +57,48 @@ const defaultProps = {
const useGetChoicesResponse = {
isLoading: false,
choices: ['severity', 'urgency', 'impact']
.map((element) => [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element,
},
{
dependent_value: '',
label: '2 - High',
value: '2',
element,
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
element,
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
element,
},
])
.flat(),
choices: [
{
dependent_value: '',
label: 'Software',
value: 'software',
element: 'category',
},
{
dependent_value: 'software',
label: 'Operation System',
value: 'os',
element: 'subcategory',
},
...['severity', 'urgency', 'impact']
.map((element) => [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element,
},
{
dependent_value: '',
label: '2 - High',
value: '2',
element,
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
element,
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
element,
},
])
.flat(),
],
};
describe('ServiceNowITSMParamsFields renders', () => {
@ -101,6 +117,8 @@ describe('ServiceNowITSMParamsFields renders', () => {
expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy();
@ -153,6 +171,36 @@ describe('ServiceNowITSMParamsFields renders', () => {
});
});
test('it transforms the categories to options correctly', async () => {
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
act(() => {
onChoices(useGetChoicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([
{
value: 'software',
text: 'Software',
},
]);
});
test('it transforms the subcategories to options correctly', async () => {
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
act(() => {
onChoices(useGetChoicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([
{
text: 'Operation System',
value: 'os',
},
]);
});
test('it transforms the options correctly', async () => {
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
act(() => {
@ -179,6 +227,8 @@ describe('ServiceNowITSMParamsFields renders', () => {
{ dataTestSubj: '[data-test-subj="urgencySelect"]', key: 'urgency' },
{ dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' },
{ dataTestSubj: '[data-test-subj="impactSelect"]', key: 'impact' },
{ dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' },
{ dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' },
];
simpleFields.forEach((field) =>

View file

@ -16,17 +16,22 @@ import {
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionParamsProps } from '../../../../types';
import { ServiceNowITSMActionParams, Choice, Options } from './types';
import { ServiceNowITSMActionParams, Choice, Fields } from './types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
import { useGetChoices } from './use_get_choices';
import { choicesToEuiOptions } from './helpers';
import * as i18n from './translations';
const useGetChoicesFields = ['urgency', 'severity', 'impact'];
const defaultOptions: Options = {
const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory'];
const defaultFields: Fields = {
category: [],
subcategory: [],
urgency: [],
severity: [],
impact: [],
priority: [],
};
const ServiceNowParamsFields: React.FunctionComponent<
@ -48,7 +53,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
[actionParams.subActionParams]
);
const [options, setOptions] = useState<Options>(defaultOptions);
const [choices, setChoices] = useState<Fields>(defaultFields);
const editSubActionProperty = useCallback(
(key: string, value: any) => {
@ -73,19 +78,32 @@ const ServiceNowParamsFields: React.FunctionComponent<
[editSubActionProperty]
);
const onChoicesSuccess = (choices: Choice[]) =>
setOptions(
choices.reduce(
(acc, choice) => ({
const onChoicesSuccess = useCallback((values: Choice[]) => {
setChoices(
values.reduce(
(acc, value) => ({
...acc,
[choice.element]: [
...(acc[choice.element] != null ? acc[choice.element] : []),
{ value: choice.value, text: choice.label },
],
[value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value],
}),
defaultOptions
defaultFields
)
);
}, []);
const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]);
const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]);
const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]);
const subcategoryOptions = useMemo(
() =>
choicesToEuiOptions(
choices.subcategory.filter(
(subcategory) => subcategory.dependent_value === incident.category
)
),
[choices.subcategory, incident.category]
);
const { isLoading: isLoadingChoices } = useGetChoices({
http,
@ -140,7 +158,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={options.urgency}
options={urgencyOptions}
value={incident.urgency ?? ''}
onChange={(e) => editSubActionProperty('urgency', e.target.value)}
/>
@ -155,7 +173,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={options.severity}
options={severityOptions}
value={incident.severity ?? ''}
onChange={(e) => editSubActionProperty('severity', e.target.value)}
/>
@ -169,7 +187,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={options.impact}
options={impactOptions}
value={incident.impact ?? ''}
onChange={(e) => editSubActionProperty('impact', e.target.value)}
/>
@ -177,6 +195,47 @@ const ServiceNowParamsFields: React.FunctionComponent<
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={categoryOptions}
value={incident.category ?? undefined}
onChange={(e) => {
editAction(
'subActionParams',
{
incident: { ...incident, category: e.target.value, subcategory: null },
comments,
},
index
);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={incident.subcategory ?? ''}
onChange={(e) => editSubActionProperty('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
error={errors['subActionParams.incident.short_description']}

View file

@ -13,7 +13,6 @@ import {
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiSelectOption,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionParamsProps } from '../../../../types';
@ -23,6 +22,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var
import * as i18n from './translations';
import { useGetChoices } from './use_get_choices';
import { ServiceNowSIRActionParams, Fields, Choice } from './types';
import { choicesToEuiOptions } from './helpers';
const useGetChoicesFields = ['category', 'subcategory', 'priority'];
const defaultFields: Fields = {
@ -31,9 +31,6 @@ const defaultFields: Fields = {
priority: [],
};
const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));
const ServiceNowSIRParamsFields: React.FunctionComponent<
ActionParamsProps<ServiceNowSIRActionParams>
> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => {
@ -218,16 +215,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
disabled={isLoadingChoices}
options={priorityOptions}
value={incident.priority ?? undefined}
onChange={(e) => {
editAction(
'subActionParams',
{
incident: { ...incident, priority: e.target.value },
comments,
},
index
);
}}
onChange={(e) => editSubActionProperty('priority', e.target.value)}
/>
</EuiFormRow>
<EuiSpacer size="m" />

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { EuiSelectOption } from '@elastic/eui';
import { UserConfiguredActionConnector } from '../../../../types';
import {
ExecutorSubActionPushParamsITSM,
@ -45,4 +44,3 @@ export interface Choice {
}
export type Fields = Record<string, Choice[]>;
export type Options = Record<string, EuiSelectOption[]>;

View file

@ -119,6 +119,8 @@ function getServiceNowActionParams(): ServiceNowActionParams {
impact: '2',
severity: '2',
urgency: '2',
category: null,
subcategory: null,
externalId: null,
},
comments: [],

View file

@ -30,6 +30,8 @@ export function initPlugin(router: IRouter, path: string) {
severity: schema.string({ defaultValue: '1' }),
urgency: schema.string({ defaultValue: '1' }),
impact: schema.string({ defaultValue: '1' }),
category: schema.maybe(schema.string()),
subcategory: schema.maybe(schema.string()),
}),
},
},

View file

@ -40,6 +40,8 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
severity: '1',
short_description: 'a title',
urgency: '1',
category: 'software',
subcategory: 'software',
},
comments: [
{

View file

@ -80,7 +80,13 @@ export default ({ getService }: FtrProviderContext): void => {
id: connector.id,
name: connector.name,
type: connector.actionTypeId,
fields: { urgency: '2', impact: '2', severity: '2' },
fields: {
urgency: '2',
impact: '2',
severity: '2',
category: 'software',
subcategory: 'os',
},
}).connector,
})
.expect(200);
@ -143,7 +149,13 @@ export default ({ getService }: FtrProviderContext): void => {
id: connector.id,
name: connector.name,
type: connector.actionTypeId,
fields: { urgency: '2', impact: '2', severity: '2' },
fields: {
urgency: '2',
impact: '2',
severity: '2',
category: 'software',
subcategory: 'os',
},
}).connector,
})
.expect(200);
@ -196,7 +208,13 @@ export default ({ getService }: FtrProviderContext): void => {
id: connector.id,
name: connector.name,
type: connector.actionTypeId,
fields: { urgency: '2', impact: '2', severity: '2' },
fields: {
urgency: '2',
impact: '2',
severity: '2',
category: 'software',
subcategory: 'os',
},
}).connector,
})
.expect(200);
@ -268,7 +286,13 @@ export default ({ getService }: FtrProviderContext): void => {
id: connector.id,
name: connector.name,
type: connector.actionTypeId,
fields: { urgency: '2', impact: '2', severity: '2' },
fields: {
urgency: '2',
impact: '2',
severity: '2',
category: 'software',
subcategory: 'os',
},
}).connector,
})
.expect(200);

View file

@ -359,7 +359,13 @@ export default ({ getService }: FtrProviderContext): void => {
id: connector.id,
name: connector.name,
type: connector.actionTypeId,
fields: { urgency: '2', impact: '2', severity: '2' },
fields: {
urgency: '2',
impact: '2',
severity: '2',
category: 'software',
subcategory: 'os',
},
}).connector,
})
.expect(200);

View file

@ -476,6 +476,8 @@ export default ({ getService }: FtrProviderContext): void => {
impact: null,
severity: null,
urgency: null,
category: null,
subcategory: null,
},
},
created_by: {