[SIEM] Add rule notifications (#59004) (#61190)

## Summary

Allow defining notifications that will trigger whenever the rule created new signals.

Requires:
- https://github.com/elastic/kibana/pull/58395
- https://github.com/elastic/kibana/pull/58964
- https://github.com/elastic/kibana/pull/60832


![Screenshot 2020-03-02 at 10 19 18](https://user-images.githubusercontent.com/5188868/75662390-4fe8bf00-5c6f-11ea-943f-591367348b91.png)

![Screenshot 2020-03-02 at 10 13 00](https://user-images.githubusercontent.com/5188868/75662421-5e36db00-5c6f-11ea-9317-d158cddf4344.png)


### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials
- [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)
- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)
- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)

Co-authored-by: patrykkopycinski <patryk.kopycinski@elastic.co>
This commit is contained in:
Garrett Spong 2020-03-24 19:42:07 -06:00 committed by GitHub
parent c11a27634d
commit 31084975fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1054 additions and 184 deletions

View file

@ -65,6 +65,8 @@ export const INTERNAL_IDENTIFIER = '__internal';
export const INTERNAL_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_id`;
export const INTERNAL_RULE_ALERT_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_alert_id`;
export const INTERNAL_IMMUTABLE_KEY = `${INTERNAL_IDENTIFIER}_immutable`;
export const INTERNAL_NOTIFICATION_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_id`;
export const INTERNAL_NOTIFICATION_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_rule_id`;
/**
* Detection engine routes
@ -99,4 +101,14 @@ export const UNAUTHENTICATED_USER = 'Unauthenticated';
*/
export const MINIMUM_ML_LICENSE = 'platinum';
/*
Rule notifications options
*/
export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
'.email',
'.slack',
'.pagerduty',
'.webhook',
];
export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions';
export const NOTIFICATION_THROTTLE_RULE = 'rule';

View file

@ -20,7 +20,9 @@ export const CREATE_AND_ACTIVATE_BTN = '[data-test-subj="create-activate"]';
export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]';
export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="continue"]';
export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]';
export const SCHEDULE_CONTINUE_BUTTON = '[data-test-subj="schedule-continue"]';
export const FALSE_POSITIVES_INPUT =
'[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] input';
@ -43,7 +45,8 @@ export const RULE_DESCRIPTION_INPUT =
export const RULE_NAME_INPUT =
'[data-test-subj="detectionEngineStepAboutRuleName"] [data-test-subj="input"]';
export const SEVERITY_DROPDOWN = '[data-test-subj="select"]';
export const SEVERITY_DROPDOWN =
'[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]';
export const TAGS_INPUT =
'[data-test-subj="detectionEngineStepAboutRuleTags"] [data-test-subj="comboBoxSearchInput"]';

View file

@ -21,11 +21,13 @@ import {
REFERENCE_URLS_INPUT,
RULE_DESCRIPTION_INPUT,
RULE_NAME_INPUT,
SCHEDULE_CONTINUE_BUTTON,
SEVERITY_DROPDOWN,
TAGS_INPUT,
} from '../screens/create_new_rule';
export const createAndActivateRule = () => {
cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true });
cy.get(CREATE_AND_ACTIVATE_BTN).click({ force: true });
cy.get(CREATE_AND_ACTIVATE_BTN).should('not.exist');
};

View file

@ -39,7 +39,7 @@ describe('Detections Rules API', () => {
await addRule({ rule: ruleMock, signal: abortCtrl.signal });
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', {
body:
'{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[]}',
'{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[],"throttle":null}',
method: 'POST',
signal: abortCtrl.signal,
});
@ -291,7 +291,7 @@ describe('Detections Rules API', () => {
await duplicateRules({ rules: rulesMock.data });
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_create', {
body:
'[{"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1},{"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1}]',
'[{"actions":[],"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1},{"actions":[],"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1}]',
method: 'POST',
});
});

View file

@ -32,9 +32,11 @@ export const ruleMock: NewRule = {
to: 'now',
type: 'query',
threat: [],
throttle: null,
};
export const savedRuleMock: Rule = {
actions: [],
created_at: 'mm/dd/yyyyTHH:MM:sssz',
created_by: 'mockUser',
description: 'some desc',
@ -65,6 +67,7 @@ export const savedRuleMock: Rule = {
to: 'now',
type: 'query',
threat: [],
throttle: null,
updated_at: 'mm/dd/yyyyTHH:MM:sssz',
updated_by: 'mockUser',
};
@ -75,6 +78,7 @@ export const rulesMock: FetchRulesResponse = {
total: 2,
data: [
{
actions: [],
created_at: '2020-02-14T19:49:28.178Z',
updated_at: '2020-02-14T19:49:28.320Z',
created_by: 'elastic',
@ -103,9 +107,11 @@ export const rulesMock: FetchRulesResponse = {
to: 'now',
type: 'query',
threat: [],
throttle: null,
version: 1,
},
{
actions: [],
created_at: '2020-02-14T19:49:28.189Z',
updated_at: '2020-02-14T19:49:28.326Z',
created_by: 'elastic',
@ -133,6 +139,7 @@ export const rulesMock: FetchRulesResponse = {
to: 'now',
type: 'query',
threat: [],
throttle: null,
version: 1,
},
],

View file

@ -13,6 +13,19 @@ export const RuleTypeSchema = t.keyof({
});
export type RuleType = t.TypeOf<typeof RuleTypeSchema>;
/**
* Params is an "record", since it is a type of AlertActionParams which is action templates.
* @see x-pack/plugins/alerting/common/alert.ts
*/
export const action = t.exact(
t.type({
group: t.string,
id: t.string,
action_type_id: t.string,
params: t.record(t.string, t.any),
})
);
export const NewRuleSchema = t.intersection([
t.type({
description: t.string,
@ -24,6 +37,7 @@ export const NewRuleSchema = t.intersection([
type: RuleTypeSchema,
}),
t.partial({
actions: t.array(action),
anomaly_threshold: t.number,
created_by: t.string,
false_positives: t.array(t.string),
@ -40,6 +54,7 @@ export const NewRuleSchema = t.intersection([
saved_id: t.string,
tags: t.array(t.string),
threat: t.array(t.unknown),
throttle: t.union([t.string, t.null]),
to: t.string,
updated_by: t.string,
note: t.string,
@ -54,9 +69,15 @@ export interface AddRulesProps {
signal: AbortSignal;
}
const MetaRule = t.type({
from: t.string,
});
const MetaRule = t.intersection([
t.type({
from: t.string,
}),
t.partial({
throttle: t.string,
kibanaSiemAppUrl: t.string,
}),
]);
export const RuleSchema = t.intersection([
t.type({
@ -81,6 +102,8 @@ export const RuleSchema = t.intersection([
threat: t.array(t.unknown),
updated_at: t.string,
updated_by: t.string,
actions: t.array(action),
throttle: t.union([t.string, t.null]),
}),
t.partial({
anomaly_threshold: t.number,

View file

@ -31,6 +31,7 @@ describe('useRule', () => {
expect(result.current).toEqual([
false,
{
actions: [],
created_at: 'mm/dd/yyyyTHH:MM:sssz',
created_by: 'mockUser',
description: 'some desc',
@ -59,6 +60,7 @@ describe('useRule', () => {
severity: 'high',
tags: ['APM'],
threat: [],
throttle: null,
to: 'now',
type: 'query',
updated_at: 'mm/dd/yyyyTHH:MM:sssz',

View file

@ -58,6 +58,7 @@ describe('useRules', () => {
{
data: [
{
actions: [],
created_at: '2020-02-14T19:49:28.178Z',
created_by: 'elastic',
description:
@ -82,6 +83,7 @@ describe('useRules', () => {
severity: 'high',
tags: ['Elastic', 'Endpoint'],
threat: [],
throttle: null,
to: 'now',
type: 'query',
updated_at: '2020-02-14T19:49:28.320Z',
@ -89,6 +91,7 @@ describe('useRules', () => {
version: 1,
},
{
actions: [],
created_at: '2020-02-14T19:49:28.189Z',
created_by: 'elastic',
description:
@ -113,6 +116,7 @@ describe('useRules', () => {
severity: 'medium',
tags: ['Elastic', 'Endpoint'],
threat: [],
throttle: null,
to: 'now',
type: 'query',
updated_at: '2020-02-14T19:49:28.326Z',

View file

@ -6,7 +6,7 @@
import { esFilters } from '../../../../../../../../../../src/plugins/data/public';
import { Rule, RuleError } from '../../../../../containers/detection_engine/rules';
import { AboutStepRule, DefineStepRule, ScheduleStepRule } from '../../types';
import { AboutStepRule, ActionsStepRule, DefineStepRule, ScheduleStepRule } from '../../types';
import { FieldValueQueryBar } from '../../components/query_bar';
export const mockQueryBar: FieldValueQueryBar = {
@ -40,6 +40,7 @@ export const mockQueryBar: FieldValueQueryBar = {
};
export const mockRule = (id: string): Rule => ({
actions: [],
created_at: '2020-01-10T21:11:45.839Z',
updated_at: '2020-01-10T21:11:45.839Z',
created_by: 'elastic',
@ -70,11 +71,13 @@ export const mockRule = (id: string): Rule => ({
to: 'now',
type: 'saved_query',
threat: [],
throttle: null,
note: '# this is some markdown documentation',
version: 1,
});
export const mockRuleWithEverything = (id: string): Rule => ({
actions: [],
created_at: '2020-01-10T21:11:45.839Z',
updated_at: '2020-01-10T21:11:45.839Z',
created_by: 'elastic',
@ -142,6 +145,7 @@ export const mockRuleWithEverything = (id: string): Rule => ({
],
},
],
throttle: null,
note: '# this is some markdown documentation',
version: 1,
});
@ -175,6 +179,14 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({
note: '# this is some markdown documentation',
});
export const mockActionsStepRule = (isNew = false, enabled = false): ActionsStepRule => ({
isNew,
actions: [],
kibanaSiemAppUrl: 'http://localhost:5601/app/siem',
enabled,
throttle: null,
});
export const mockDefineStepRule = (isNew = false): DefineStepRule => ({
isNew,
ruleType: 'query',
@ -188,9 +200,8 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({
},
});
export const mockScheduleStepRule = (isNew = false, enabled = false): ScheduleStepRule => ({
export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({
isNew,
enabled,
interval: '5m',
from: '6m',
to: 'now',

View file

@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NextStep renders correctly against snapshot 1`] = `
<Fragment>
<EuiHorizontalRule
margin="m"
/>
<EuiFlexGroup
alignItems="center"
gutterSize="xs"
justifyContent="flexEnd"
responsive={false}
>
<EuiFlexItem
grow={false}
>
<EuiButton
data-test-subj="nextStep-continue"
fill={true}
isDisabled={false}
onClick={[MockFunction]}
>
Continue
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
`;

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { NextStep } from './index';
describe('NextStep', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(<NextStep onClick={jest.fn()} isDisabled={false} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import * as RuleI18n from '../../translations';
interface NextStepProps {
onClick: () => Promise<void>;
isDisabled: boolean;
dataTestSubj?: string;
}
export const NextStep = React.memo<NextStepProps>(
({ onClick, isDisabled, dataTestSubj = 'nextStep-continue' }) => (
<>
<EuiHorizontalRule margin="m" />
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={onClick} isDisabled={isDisabled} data-test-subj={dataTestSubj}>
{RuleI18n.CONTINUE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)
);
NextStep.displayName = 'NextStep';

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import deepMerge from 'deepmerge';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { loadActionTypes } from '../../../../../../../../../plugins/triggers_actions_ui/public/application/lib/action_connector_api';
import { SelectField } from '../../../../../shared_imports';
import {
ActionForm,
ActionType,
} from '../../../../../../../../../plugins/triggers_actions_ui/public';
import { AlertAction } from '../../../../../../../../../plugins/alerting/common';
import { useKibana } from '../../../../../lib/kibana';
import { NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS } from '../../../../../../common/constants';
type ThrottleSelectField = typeof SelectField;
const DEFAULT_ACTION_GROUP_ID = 'default';
const DEFAULT_ACTION_MESSAGE =
'Rule {{context.rule.name}} generated {{state.signals_count}} signals';
export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables }) => {
const [supportedActionTypes, setSupportedActionTypes] = useState<ActionType[] | undefined>();
const {
http,
triggers_actions_ui: { actionTypeRegistry },
notifications,
} = useKibana().services;
const setActionIdByIndex = useCallback(
(id: string, index: number) => {
const updatedActions = [...(field.value as Array<Partial<AlertAction>>)];
updatedActions[index] = deepMerge(updatedActions[index], { id });
field.setValue(updatedActions);
},
[field]
);
const setAlertProperty = useCallback(
(updatedActions: AlertAction[]) => field.setValue(updatedActions),
[field]
);
const setActionParamsProperty = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(key: string, value: any, index: number) => {
const updatedActions = [...(field.value as AlertAction[])];
updatedActions[index].params[key] = value;
field.setValue(updatedActions);
},
[field]
);
useEffect(() => {
(async function() {
const actionTypes = await loadActionTypes({ http });
const supportedTypes = actionTypes.filter(actionType =>
NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id)
);
setSupportedActionTypes(supportedTypes);
})();
}, []);
if (!supportedActionTypes) return <></>;
return (
<ActionForm
actions={field.value as AlertAction[]}
messageVariables={messageVariables}
defaultActionGroupId={DEFAULT_ACTION_GROUP_ID}
setActionIdByIndex={setActionIdByIndex}
setAlertProperty={setAlertProperty}
setActionParamsProperty={setActionParamsProperty}
http={http}
actionTypeRegistry={actionTypeRegistry}
actionTypes={supportedActionTypes}
defaultActionMessage={DEFAULT_ACTION_MESSAGE}
toastNotifications={notifications.toasts}
/>
);
};

View file

@ -15,6 +15,17 @@ import { stepAboutDefaultValue } from './default_value';
const theme = () => ({ eui: euiDarkVars, darkMode: true });
/* eslint-disable no-console */
// Silence until enzyme fixed to use ReactTestUtils.act()
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
/* eslint-enable no-console */
describe('StepAboutRuleComponent', () => {
test('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => {
const wrapper = shallow(

View file

@ -4,22 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiAccordion,
EuiButton,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiButtonEmpty,
} from '@elastic/eui';
import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiButtonEmpty } from '@elastic/eui';
import React, { FC, memo, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import { setFieldValue } from '../../helpers';
import { RuleStepProps, RuleStep, AboutStepRule } from '../../types';
import * as RuleI18n from '../../translations';
import { AddItem } from '../add_item_form';
import { StepRuleDescription } from '../description_step';
import { AddMitreThreat } from '../mitre';
@ -38,6 +29,7 @@ import { isUrlInvalid } from './helpers';
import { schema } from './schema';
import * as I18n from './translations';
import { StepContentWrapper } from '../step_content_wrapper';
import { NextStep } from '../next_step';
import { MarkdownEditorForm } from '../../../../../components/markdown_editor/form';
const CommonUseField = getUseField({ component: Field });
@ -276,27 +268,9 @@ const StepAboutRuleComponent: FC<StepAboutRuleProps> = ({
</FormDataProvider>
</Form>
</StepContentWrapper>
{!isUpdateView && (
<>
<EuiHorizontalRule margin="m" />
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="about-continue"
fill
onClick={onSubmit}
isDisabled={isLoading}
>
{RuleI18n.CONTINUE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
<NextStep dataTestSubj="about-continue" onClick={onSubmit} isDisabled={isLoading} />
)}
</>
);

View file

@ -15,7 +15,7 @@ import {
EuiFlexGroup,
EuiResizeObserver,
} from '@elastic/eui';
import React, { memo, useState } from 'react';
import React, { memo, useCallback, useState } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
@ -71,9 +71,12 @@ const StepAboutRuleToggleDetailsComponent: React.FC<StepPanelProps> = ({
const [selectedToggleOption, setToggleOption] = useState('details');
const [aboutPanelHeight, setAboutPanelHeight] = useState(0);
const onResize = (e: { height: number; width: number }) => {
setAboutPanelHeight(e.height);
};
const onResize = useCallback(
(e: { height: number; width: number }) => {
setAboutPanelHeight(e.height);
},
[setAboutPanelHeight]
);
return (
<MyPanel>
@ -85,7 +88,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC<StepPanelProps> = ({
)}
{stepData != null && stepDataDetails != null && (
<FlexGroupFullHeight gutterSize="xs" direction="column">
<EuiFlexItem grow={1}>
<EuiFlexItem grow={1} key="header">
<HeaderSection title={i18n.ABOUT_TEXT}>
{!isEmpty(stepDataDetails.note) && stepDataDetails.note.trim() !== '' && (
<EuiButtonGroup
@ -99,7 +102,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC<StepPanelProps> = ({
)}
</HeaderSection>
</EuiFlexItem>
<EuiFlexItem grow={5}>
<EuiFlexItem grow={5} key="details">
{selectedToggleOption === 'details' ? (
<EuiResizeObserver data-test-subj="stepAboutDetailsContent" onResize={onResize}>
{resizeRef => (

View file

@ -4,14 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButtonEmpty,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiButton,
} from '@elastic/eui';
import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui';
import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
@ -23,7 +16,6 @@ import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/trans
import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider';
import { useUiSetting$ } from '../../../../../lib/kibana';
import { setFieldValue, isMlRule } from '../../helpers';
import * as RuleI18n from '../../translations';
import { DefineStepRule, RuleStep, RuleStepProps } from '../../types';
import { StepRuleDescription } from '../description_step';
import { QueryBarDefineRule } from '../query_bar';
@ -32,6 +24,7 @@ import { AnomalyThresholdSlider } from '../anomaly_threshold_slider';
import { MlJobSelect } from '../ml_job_select';
import { PickTimeline } from '../pick_timeline';
import { StepContentWrapper } from '../step_content_wrapper';
import { NextStep } from '../next_step';
import {
Field,
Form,
@ -269,22 +262,9 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
</FormDataProvider>
</Form>
</StepContentWrapper>
{!isUpdateView && (
<>
<EuiHorizontalRule margin="m" />
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={onSubmit} isDisabled={isLoading} data-test-subj="continue">
{RuleI18n.CONTINUE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
<NextStep dataTestSubj="define-continue" onClick={onSubmit} isDisabled={isLoading} />
)}
</>
);

View file

@ -0,0 +1,184 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer } from '@elastic/eui';
import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { setFieldValue } from '../../helpers';
import { RuleStep, RuleStepProps, ActionsStepRule } from '../../types';
import { StepRuleDescription } from '../description_step';
import { Form, UseField, useForm } from '../../../../../shared_imports';
import { StepContentWrapper } from '../step_content_wrapper';
import { ThrottleSelectField, THROTTLE_OPTIONS } from '../throttle_select_field';
import { RuleActionsField } from '../rule_actions_field';
import { useKibana } from '../../../../../lib/kibana';
import { schema } from './schema';
import * as I18n from './translations';
interface StepRuleActionsProps extends RuleStepProps {
defaultValues?: ActionsStepRule | null;
actionMessageParams: string[];
}
const stepActionsDefaultValue = {
enabled: true,
isNew: true,
actions: [],
kibanaSiemAppUrl: '',
throttle: THROTTLE_OPTIONS[0].value,
};
const GhostFormField = () => <></>;
const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
addPadding = false,
defaultValues,
isReadOnlyView,
isLoading,
isUpdateView = false,
setStepData,
setForm,
actionMessageParams,
}) => {
const [myStepData, setMyStepData] = useState<ActionsStepRule>(stepActionsDefaultValue);
const {
services: { application },
} = useKibana();
const { form } = useForm({
defaultValue: myStepData,
options: { stripEmptyFields: false },
schema,
});
const kibanaAbsoluteUrl = useMemo(() => application.getUrlForApp('siem', { absolute: true }), [
application,
]);
const onSubmit = useCallback(
async (enabled: boolean) => {
if (setStepData) {
setStepData(RuleStep.ruleActions, null, false);
const { isValid: newIsValid, data } = await form.submit();
if (newIsValid) {
setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid);
setMyStepData({ ...data, isNew: false } as ActionsStepRule);
}
}
},
[form]
);
useEffect(() => {
const { isNew, ...initDefaultValue } = myStepData;
if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) {
const myDefaultValues = {
...defaultValues,
isNew: false,
};
setMyStepData(myDefaultValues);
setFieldValue(form, schema, myDefaultValues);
}
}, [defaultValues]);
useEffect(() => {
if (setForm != null) {
setForm(RuleStep.ruleActions, form);
}
}, [form]);
const updateThrottle = useCallback(throttle => setMyStepData({ ...myStepData, throttle }), [
myStepData,
setMyStepData,
]);
const throttleFieldComponentProps = useMemo(
() => ({
idAria: 'detectionEngineStepRuleActionsThrottle',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepRuleActionsThrottle',
hasNoInitialSelection: false,
handleChange: updateThrottle,
euiFieldProps: {
options: THROTTLE_OPTIONS,
},
}),
[isLoading, updateThrottle]
);
return isReadOnlyView && myStepData != null ? (
<StepContentWrapper addPadding={addPadding}>
<StepRuleDescription schema={schema} data={myStepData} />
</StepContentWrapper>
) : (
<>
<StepContentWrapper addPadding={!isUpdateView}>
<Form form={form} data-test-subj="stepRuleActions">
<UseField
path="throttle"
component={ThrottleSelectField}
componentProps={throttleFieldComponentProps}
/>
{myStepData.throttle !== stepActionsDefaultValue.throttle && (
<>
<EuiSpacer />
<UseField
path="actions"
defaultValue={myStepData.actions}
component={RuleActionsField}
componentProps={{
messageVariables: actionMessageParams,
}}
/>
<UseField
path="kibanaSiemAppUrl"
defaultValue={kibanaAbsoluteUrl}
component={GhostFormField}
/>
</>
)}
</Form>
</StepContentWrapper>
{!isUpdateView && (
<>
<EuiHorizontalRule margin="m" />
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButton
fill={false}
isDisabled={isLoading}
isLoading={isLoading}
onClick={onSubmit.bind(null, false)}
>
{I18n.COMPLETE_WITHOUT_ACTIVATING}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
isDisabled={isLoading}
isLoading={isLoading}
onClick={onSubmit.bind(null, true)}
data-test-subj="create-activate"
>
{I18n.COMPLETE_WITH_ACTIVATING}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</>
);
};
export const StepRuleActions = memo(StepRuleActionsComponent);

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormSchema } from '../../../../../shared_imports';
export const schema: FormSchema = {
actions: {},
kibanaSiemAppUrl: {},
throttle: {
label: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleLabel',
{
defaultMessage: 'Actions frequency',
}
),
helpText: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleHelpText',
{
defaultMessage:
'Select when automated actions should be performed if a rule evaluates as true.',
}
),
},
};

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const COMPLETE_WITHOUT_ACTIVATING = i18n.translate(
'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithoutActivatingTitle',
{
defaultMessage: 'Create rule without activating it',
}
);
export const COMPLETE_WITH_ACTIVATING = i18n.translate(
'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithActivatingTitle',
{
defaultMessage: 'Create & activate rule',
}
);

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import React, { FC, memo, useCallback, useEffect, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import styled from 'styled-components';
@ -15,8 +14,8 @@ import { StepRuleDescription } from '../description_step';
import { ScheduleItem } from '../schedule_item_form';
import { Form, UseField, useForm } from '../../../../../shared_imports';
import { StepContentWrapper } from '../step_content_wrapper';
import { NextStep } from '../next_step';
import { schema } from './schema';
import * as I18n from './translations';
interface StepScheduleRuleProps extends RuleStepProps {
defaultValues?: ScheduleStepRule | null;
@ -27,7 +26,6 @@ const RestrictedWidthContainer = styled.div`
`;
const stepScheduleDefaultValue = {
enabled: true,
interval: '5m',
isNew: true,
from: '1m',
@ -51,19 +49,16 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
schema,
});
const onSubmit = useCallback(
async (enabled: boolean) => {
if (setStepData) {
setStepData(RuleStep.scheduleRule, null, false);
const { isValid: newIsValid, data } = await form.submit();
if (newIsValid) {
setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid);
setMyStepData({ ...data, isNew: false } as ScheduleStepRule);
}
const onSubmit = useCallback(async () => {
if (setStepData) {
setStepData(RuleStep.scheduleRule, null, false);
const { isValid: newIsValid, data } = await form.submit();
if (newIsValid) {
setStepData(RuleStep.scheduleRule, { ...data }, newIsValid);
setMyStepData({ ...data, isNew: false } as ScheduleStepRule);
}
},
[form]
);
}
}, [form]);
useEffect(() => {
const { isNew, ...initDefaultValue } = myStepData;
@ -118,37 +113,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
</StepContentWrapper>
{!isUpdateView && (
<>
<EuiHorizontalRule margin="m" />
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButton
fill={false}
isDisabled={isLoading}
isLoading={isLoading}
onClick={onSubmit.bind(null, false)}
>
{I18n.COMPLETE_WITHOUT_ACTIVATING}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
isDisabled={isLoading}
isLoading={isLoading}
onClick={onSubmit.bind(null, true)}
data-test-subj="create-activate"
>
{I18n.COMPLETE_WITH_ACTIVATING}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
<NextStep dataTestSubj="schedule-continue" onClick={onSubmit} isDisabled={isLoading} />
)}
</>
);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import {
NOTIFICATION_THROTTLE_RULE,
NOTIFICATION_THROTTLE_NO_ACTIONS,
} from '../../../../../../common/constants';
import { SelectField } from '../../../../../shared_imports';
export const THROTTLE_OPTIONS = [
{ value: NOTIFICATION_THROTTLE_NO_ACTIONS, text: 'Perform no actions' },
{ value: NOTIFICATION_THROTTLE_RULE, text: 'On each rule execution' },
{ value: '1h', text: 'Hourly' },
{ value: '1d', text: 'Daily' },
{ value: '7d', text: 'Weekly' },
];
type ThrottleSelectField = typeof SelectField;
export const ThrottleSelectField: ThrottleSelectField = props => {
const onChange = useCallback(
e => {
const throttle = e.target.value;
props.field.setValue(throttle);
props.handleChange(throttle);
},
[props.field.setValue, props.handleChange]
);
const newEuiFieldProps = { ...props.euiFieldProps, onChange };
return <SelectField {...props} euiFieldProps={newEuiFieldProps} />;
};

View file

@ -9,7 +9,9 @@ import {
DefineStepRuleJson,
ScheduleStepRuleJson,
AboutStepRuleJson,
ActionsStepRuleJson,
AboutStepRule,
ActionsStepRule,
ScheduleStepRule,
DefineStepRule,
} from '../types';
@ -18,6 +20,7 @@ import {
formatDefineStepData,
formatScheduleStepData,
formatAboutStepData,
formatActionsStepData,
formatRule,
filterRuleFieldsForType,
} from './helpers';
@ -26,6 +29,7 @@ import {
mockQueryBar,
mockScheduleStepRule,
mockAboutStepRule,
mockActionsStepRule,
} from '../all/__mocks__/mock';
describe('helpers', () => {
@ -241,7 +245,6 @@ describe('helpers', () => {
test('returns formatted object as ScheduleStepRuleJson', () => {
const result: ScheduleStepRuleJson = formatScheduleStepData(mockData);
const expected = {
enabled: false,
from: 'now-660s',
to: 'now',
interval: '5m',
@ -260,7 +263,6 @@ describe('helpers', () => {
delete mockStepData.to;
const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData);
const expected = {
enabled: false,
from: 'now-660s',
to: 'now',
interval: '5m',
@ -279,7 +281,6 @@ describe('helpers', () => {
};
const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData);
const expected = {
enabled: false,
from: 'now-660s',
to: 'now',
interval: '5m',
@ -298,7 +299,6 @@ describe('helpers', () => {
};
const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData);
const expected = {
enabled: false,
from: 'now-300s',
to: 'now',
interval: '5m',
@ -317,7 +317,6 @@ describe('helpers', () => {
};
const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData);
const expected = {
enabled: false,
from: 'now-360s',
to: 'now',
interval: 'random',
@ -503,19 +502,164 @@ describe('helpers', () => {
});
});
describe('formatActionsStepData', () => {
let mockData: ActionsStepRule;
beforeEach(() => {
mockData = mockActionsStepRule();
});
test('returns formatted object as ActionsStepRuleJson', () => {
const result: ActionsStepRuleJson = formatActionsStepData(mockData);
const expected = {
actions: [],
enabled: false,
meta: {
throttle: 'no_actions',
kibanaSiemAppUrl: 'http://localhost:5601/app/siem',
},
throttle: null,
};
expect(result).toEqual(expected);
});
test('returns proper throttle value for no_actions', () => {
const mockStepData = {
...mockData,
throttle: 'no_actions',
};
const result: ActionsStepRuleJson = formatActionsStepData(mockStepData);
const expected = {
actions: [],
enabled: false,
meta: {
throttle: mockStepData.throttle,
kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl,
},
throttle: null,
};
expect(result).toEqual(expected);
});
test('returns proper throttle value for rule', () => {
const mockStepData = {
...mockData,
throttle: 'rule',
actions: [
{
group: 'default',
id: 'id',
actionTypeId: 'actionTypeId',
params: {},
},
],
};
const result: ActionsStepRuleJson = formatActionsStepData(mockStepData);
const expected = {
actions: [
{
group: mockStepData.actions[0].group,
id: mockStepData.actions[0].id,
action_type_id: mockStepData.actions[0].actionTypeId,
params: mockStepData.actions[0].params,
},
],
enabled: false,
meta: {
throttle: mockStepData.throttle,
kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl,
},
throttle: null,
};
expect(result).toEqual(expected);
});
test('returns proper throttle value for interval', () => {
const mockStepData = {
...mockData,
throttle: '1d',
actions: [
{
group: 'default',
id: 'id',
actionTypeId: 'actionTypeId',
params: {},
},
],
};
const result: ActionsStepRuleJson = formatActionsStepData(mockStepData);
const expected = {
actions: [
{
group: mockStepData.actions[0].group,
id: mockStepData.actions[0].id,
action_type_id: mockStepData.actions[0].actionTypeId,
params: mockStepData.actions[0].params,
},
],
enabled: false,
meta: {
throttle: mockStepData.throttle,
kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl,
},
throttle: mockStepData.throttle,
};
expect(result).toEqual(expected);
});
test('returns actions with action_type_id', () => {
const mockAction = {
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
params: { message: 'ML Rule generated {{state.signals_count}} signals' },
actionTypeId: '.slack',
};
const mockStepData = {
...mockData,
actions: [mockAction],
};
const result: ActionsStepRuleJson = formatActionsStepData(mockStepData);
const expected = {
actions: [
{
group: mockAction.group,
id: mockAction.id,
params: mockAction.params,
action_type_id: mockAction.actionTypeId,
},
],
enabled: false,
meta: {
throttle: null,
kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl,
},
throttle: null,
};
expect(result).toEqual(expected);
});
});
describe('formatRule', () => {
let mockAbout: AboutStepRule;
let mockDefine: DefineStepRule;
let mockSchedule: ScheduleStepRule;
let mockActions: ActionsStepRule;
beforeEach(() => {
mockAbout = mockAboutStepRule();
mockDefine = mockDefineStepRule();
mockSchedule = mockScheduleStepRule();
mockActions = mockActionsStepRule();
});
test('returns NewRule with type of saved_query when saved_id exists', () => {
const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule);
const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions);
expect(result.type).toEqual('saved_query');
});
@ -528,13 +672,18 @@ describe('helpers', () => {
saved_id: '',
},
};
const result: NewRule = formatRule(mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule);
const result: NewRule = formatRule(
mockDefineStepRuleWithoutSavedId,
mockAbout,
mockSchedule,
mockActions
);
expect(result.type).toEqual('query');
});
test('returns NewRule without id if ruleId does not exist', () => {
const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule);
const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions);
expect(result.id).toBeUndefined();
});

View file

@ -6,16 +6,24 @@
import { has, isEmpty } from 'lodash/fp';
import moment from 'moment';
import deepmerge from 'deepmerge';
import {
NOTIFICATION_THROTTLE_RULE,
NOTIFICATION_THROTTLE_NO_ACTIONS,
} from '../../../../../common/constants';
import { NewRule, RuleType } from '../../../../containers/detection_engine/rules';
import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions';
import {
AboutStepRule,
DefineStepRule,
ScheduleStepRule,
ActionsStepRule,
DefineStepRuleJson,
ScheduleStepRuleJson,
AboutStepRuleJson,
ActionsStepRuleJson,
} from '../types';
import { isMlRule } from '../helpers';
@ -136,12 +144,39 @@ export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRule
};
};
export const getAlertThrottle = (throttle: string | null) =>
throttle && ![NOTIFICATION_THROTTLE_NO_ACTIONS, NOTIFICATION_THROTTLE_RULE].includes(throttle)
? throttle
: null;
export const formatActionsStepData = (actionsStepData: ActionsStepRule): ActionsStepRuleJson => {
const {
actions = [],
enabled,
kibanaSiemAppUrl,
throttle = NOTIFICATION_THROTTLE_NO_ACTIONS,
} = actionsStepData;
return {
actions: actions.map(transformAlertToRuleAction),
enabled,
throttle: actions.length ? getAlertThrottle(throttle) : null,
meta: {
throttle: actions.length ? throttle : NOTIFICATION_THROTTLE_NO_ACTIONS,
kibanaSiemAppUrl,
},
};
};
export const formatRule = (
defineStepData: DefineStepRule,
aboutStepData: AboutStepRule,
scheduleData: ScheduleStepRule
): NewRule => ({
...formatDefineStepData(defineStepData),
...formatAboutStepData(aboutStepData),
...formatScheduleStepData(scheduleData),
});
scheduleData: ScheduleStepRule,
actionsData: ActionsStepRule
): NewRule =>
deepmerge.all([
formatDefineStepData(defineStepData),
formatAboutStepData(aboutStepData),
formatScheduleStepData(scheduleData),
formatActionsStepData(actionsData),
]) as NewRule;

View file

@ -5,7 +5,7 @@
*/
import { EuiButtonEmpty, EuiAccordion, EuiHorizontalRule, EuiPanel, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useRef, useState, useMemo } from 'react';
import { Redirect } from 'react-router-dom';
import styled, { StyledComponent } from 'styled-components';
@ -21,14 +21,27 @@ import { FormData, FormHook } from '../../../../shared_imports';
import { StepAboutRule } from '../components/step_about_rule';
import { StepDefineRule } from '../components/step_define_rule';
import { StepScheduleRule } from '../components/step_schedule_rule';
import { StepRuleActions } from '../components/step_rule_actions';
import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page';
import * as RuleI18n from '../translations';
import { redirectToDetections } from '../helpers';
import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types';
import { redirectToDetections, getActionMessageParams } from '../helpers';
import {
AboutStepRule,
DefineStepRule,
RuleStep,
RuleStepData,
ScheduleStepRule,
ActionsStepRule,
} from '../types';
import { formatRule } from './helpers';
import * as i18n from './translations';
const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.scheduleRule];
const stepsRuleOrder = [
RuleStep.defineRule,
RuleStep.aboutRule,
RuleStep.scheduleRule,
RuleStep.ruleActions,
];
const MyEuiPanel = styled(EuiPanel)<{
zindex?: number;
@ -79,22 +92,31 @@ const CreateRulePageComponent: React.FC = () => {
const defineRuleRef = useRef<EuiAccordion | null>(null);
const aboutRuleRef = useRef<EuiAccordion | null>(null);
const scheduleRuleRef = useRef<EuiAccordion | null>(null);
const ruleActionsRef = useRef<EuiAccordion | null>(null);
const stepsForm = useRef<Record<RuleStep, FormHook<FormData> | null>>({
[RuleStep.defineRule]: null,
[RuleStep.aboutRule]: null,
[RuleStep.scheduleRule]: null,
[RuleStep.ruleActions]: null,
});
const stepsData = useRef<Record<RuleStep, RuleStepData>>({
[RuleStep.defineRule]: { isValid: false, data: {} },
[RuleStep.aboutRule]: { isValid: false, data: {} },
[RuleStep.scheduleRule]: { isValid: false, data: {} },
[RuleStep.ruleActions]: { isValid: false, data: {} },
});
const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState<Record<RuleStep, boolean>>({
[RuleStep.defineRule]: false,
[RuleStep.aboutRule]: false,
[RuleStep.scheduleRule]: false,
[RuleStep.ruleActions]: false,
});
const [{ isLoading, isSaved }, setRule] = usePersistRule();
const actionMessageParams = useMemo(
() =>
getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule).ruleType),
[stepsData.current['define-rule'].data]
);
const userHasNoPermissions =
canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false;
@ -103,7 +125,7 @@ const CreateRulePageComponent: React.FC = () => {
stepsData.current[step] = { ...stepsData.current[step], data, isValid };
if (isValid) {
const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item);
if ([0, 1].includes(stepRuleIdx)) {
if ([0, 1, 2].includes(stepRuleIdx)) {
if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) {
setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]);
setIsStepRuleInEditView({
@ -120,15 +142,17 @@ const CreateRulePageComponent: React.FC = () => {
setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]);
}
} else if (
stepRuleIdx === 2 &&
stepRuleIdx === 3 &&
stepsData.current[RuleStep.defineRule].isValid &&
stepsData.current[RuleStep.aboutRule].isValid
stepsData.current[RuleStep.aboutRule].isValid &&
stepsData.current[RuleStep.scheduleRule].isValid
) {
setRule(
formatRule(
stepsData.current[RuleStep.defineRule].data as DefineStepRule,
stepsData.current[RuleStep.aboutRule].data as AboutStepRule,
stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule
stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule,
stepsData.current[RuleStep.ruleActions].data as ActionsStepRule
)
);
}
@ -177,6 +201,14 @@ const CreateRulePageComponent: React.FC = () => {
/>
);
const ruleActionsButton = (
<AccordionTitle
name="4"
title={RuleI18n.RULE_ACTIONS}
type={getAccordionType(RuleStep.ruleActions)}
/>
);
const openCloseAccordion = (accordionId: RuleStep | null) => {
if (accordionId != null) {
if (accordionId === RuleStep.defineRule && defineRuleRef.current != null) {
@ -185,6 +217,8 @@ const CreateRulePageComponent: React.FC = () => {
aboutRuleRef.current.onToggle();
} else if (accordionId === RuleStep.scheduleRule && scheduleRuleRef.current != null) {
scheduleRuleRef.current.onToggle();
} else if (accordionId === RuleStep.ruleActions && ruleActionsRef.current != null) {
ruleActionsRef.current.onToggle();
}
}
};
@ -253,7 +287,7 @@ const CreateRulePageComponent: React.FC = () => {
isLoading={isLoading || loading}
title={i18n.PAGE_TITLE}
/>
<MyEuiPanel zindex={3}>
<MyEuiPanel zindex={4}>
<StepDefineRuleAccordion
initialIsOpen={true}
id={RuleStep.defineRule}
@ -288,7 +322,7 @@ const CreateRulePageComponent: React.FC = () => {
</StepDefineRuleAccordion>
</MyEuiPanel>
<EuiSpacer size="l" />
<MyEuiPanel zindex={2}>
<MyEuiPanel zindex={3}>
<EuiAccordion
initialIsOpen={false}
id={RuleStep.aboutRule}
@ -321,7 +355,7 @@ const CreateRulePageComponent: React.FC = () => {
</EuiAccordion>
</MyEuiPanel>
<EuiSpacer size="l" />
<MyEuiPanel zindex={1}>
<MyEuiPanel zindex={2}>
<EuiAccordion
initialIsOpen={false}
id={RuleStep.scheduleRule}
@ -355,6 +389,38 @@ const CreateRulePageComponent: React.FC = () => {
/>
</EuiAccordion>
</MyEuiPanel>
<EuiSpacer size="l" />
<MyEuiPanel zindex={1}>
<EuiAccordion
initialIsOpen={false}
id={RuleStep.ruleActions}
buttonContent={ruleActionsButton}
paddingSize="xs"
ref={ruleActionsRef}
onToggle={manageAccordions.bind(null, RuleStep.ruleActions)}
extraAction={
stepsData.current[RuleStep.ruleActions].isValid && (
<EuiButtonEmpty
iconType="pencil"
size="xs"
onClick={manageIsEditable.bind(null, RuleStep.ruleActions)}
>
{i18n.EDIT_RULE}
</EuiButtonEmpty>
)
}
>
<EuiHorizontalRule margin="m" />
<StepRuleActions
addPadding={true}
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.ruleActions]}
isLoading={isLoading || loading}
setForm={setStepsForm}
setStepData={setStepData}
actionMessageParams={actionMessageParams}
/>
</EuiAccordion>
</MyEuiPanel>
</WrapperPage>
<SpyRoute />

View file

@ -31,10 +31,17 @@ import { StepPanel } from '../components/step_panel';
import { StepAboutRule } from '../components/step_about_rule';
import { StepDefineRule } from '../components/step_define_rule';
import { StepScheduleRule } from '../components/step_schedule_rule';
import { StepRuleActions } from '../components/step_rule_actions';
import { formatRule } from '../create/helpers';
import { getStepsData, redirectToDetections } from '../helpers';
import { getStepsData, redirectToDetections, getActionMessageParams } from '../helpers';
import * as ruleI18n from '../translations';
import { RuleStep, DefineStepRule, AboutStepRule, ScheduleStepRule } from '../types';
import {
RuleStep,
DefineStepRule,
AboutStepRule,
ScheduleStepRule,
ActionsStepRule,
} from '../types';
import * as i18n from './translations';
interface StepRuleForm {
@ -50,6 +57,10 @@ interface ScheduleStepRuleForm extends StepRuleForm {
data: ScheduleStepRule | null;
}
interface ActionsStepRuleForm extends StepRuleForm {
data: ActionsStepRule | null;
}
const EditRulePageComponent: FC = () => {
const [, dispatchToaster] = useStateToaster();
const {
@ -79,14 +90,20 @@ const EditRulePageComponent: FC = () => {
data: null,
isValid: false,
});
const [myActionsRuleForm, setMyActionsRuleForm] = useState<ActionsStepRuleForm>({
data: null,
isValid: false,
});
const [selectedTab, setSelectedTab] = useState<EuiTabbedContentTab>();
const stepsForm = useRef<Record<RuleStep, FormHook<FormData> | null>>({
[RuleStep.defineRule]: null,
[RuleStep.aboutRule]: null,
[RuleStep.scheduleRule]: null,
[RuleStep.ruleActions]: null,
});
const [{ isLoading, isSaved }, setRule] = usePersistRule();
const [tabHasError, setTabHasError] = useState<RuleStep[]>([]);
const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]);
const setStepsForm = useCallback(
(step: RuleStep, form: FormHook<FormData>) => {
stepsForm.current[step] = form;
@ -162,6 +179,28 @@ const EditRulePageComponent: FC = () => {
</>
),
},
{
id: RuleStep.ruleActions,
name: ruleI18n.ACTIONS,
content: (
<>
<EuiSpacer />
<StepPanel loading={loading || initLoading} title={ruleI18n.ACTIONS}>
{myActionsRuleForm.data != null && (
<StepRuleActions
isReadOnlyView={false}
isLoading={isLoading}
isUpdateView
defaultValues={myActionsRuleForm.data}
setForm={setStepsForm}
actionMessageParams={actionMessageParams}
/>
)}
<EuiSpacer />
</StepPanel>
</>
),
},
],
[
loading,
@ -170,8 +209,10 @@ const EditRulePageComponent: FC = () => {
myAboutRuleForm,
myDefineRuleForm,
myScheduleRuleForm,
myActionsRuleForm,
setStepsForm,
stepsForm,
actionMessageParams,
]
);
@ -179,14 +220,18 @@ const EditRulePageComponent: FC = () => {
const activeFormId = selectedTab?.id as RuleStep;
const activeForm = await stepsForm.current[activeFormId]?.submit();
const invalidForms = [RuleStep.aboutRule, RuleStep.defineRule, RuleStep.scheduleRule].reduce<
RuleStep[]
>((acc, step) => {
const invalidForms = [
RuleStep.aboutRule,
RuleStep.defineRule,
RuleStep.scheduleRule,
RuleStep.ruleActions,
].reduce<RuleStep[]>((acc, step) => {
if (
(step === activeFormId && activeForm != null && !activeForm?.isValid) ||
(step === RuleStep.aboutRule && !myAboutRuleForm.isValid) ||
(step === RuleStep.defineRule && !myDefineRuleForm.isValid) ||
(step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid)
(step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) ||
(step === RuleStep.ruleActions && !myActionsRuleForm.isValid)
) {
return [...acc, step];
}
@ -205,21 +250,35 @@ const EditRulePageComponent: FC = () => {
: myAboutRuleForm.data) as AboutStepRule,
(activeFormId === RuleStep.scheduleRule
? activeForm.data
: myScheduleRuleForm.data) as ScheduleStepRule
: myScheduleRuleForm.data) as ScheduleStepRule,
(activeFormId === RuleStep.ruleActions
? activeForm.data
: myActionsRuleForm.data) as ActionsStepRule
),
...(ruleId ? { id: ruleId } : {}),
});
} else {
setTabHasError(invalidForms);
}
}, [stepsForm, myAboutRuleForm, myDefineRuleForm, myScheduleRuleForm, selectedTab, ruleId]);
}, [
stepsForm,
myAboutRuleForm,
myDefineRuleForm,
myScheduleRuleForm,
myActionsRuleForm,
selectedTab,
ruleId,
]);
useEffect(() => {
if (rule != null) {
const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule });
const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({
rule,
});
setMyAboutRuleForm({ data: aboutRuleData, isValid: true });
setMyDefineRuleForm({ data: defineRuleData, isValid: true });
setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true });
setMyActionsRuleForm({ data: ruleActionsData, isValid: true });
}
}, [rule]);
@ -228,6 +287,7 @@ const EditRulePageComponent: FC = () => {
if (selectedTab != null) {
const ruleStep = selectedTab.id as RuleStep;
const respForm = await stepsForm.current[ruleStep]?.submit();
if (respForm != null) {
if (ruleStep === RuleStep.aboutRule) {
setMyAboutRuleForm({
@ -244,6 +304,11 @@ const EditRulePageComponent: FC = () => {
data: respForm.data as ScheduleStepRule,
isValid: respForm.isValid,
});
} else if (ruleStep === RuleStep.ruleActions) {
setMyActionsRuleForm({
data: respForm.data as ActionsStepRule,
isValid: respForm.isValid,
});
}
}
}
@ -255,10 +320,13 @@ const EditRulePageComponent: FC = () => {
useEffect(() => {
if (rule != null) {
const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule });
const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({
rule,
});
setMyAboutRuleForm({ data: aboutRuleData, isValid: true });
setMyDefineRuleForm({ data: defineRuleData, isValid: true });
setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true });
setMyActionsRuleForm({ data: ruleActionsData, isValid: true });
}
}, [rule]);
@ -303,6 +371,8 @@ const EditRulePageComponent: FC = () => {
return ruleI18n.DEFINITION;
} else if (t === RuleStep.scheduleRule) {
return ruleI18n.SCHEDULE;
} else if (t === RuleStep.ruleActions) {
return ruleI18n.RULE_ACTIONS;
}
return t;
})

View file

@ -10,6 +10,7 @@ import {
getScheduleStepsData,
getStepsData,
getAboutStepsData,
getActionsStepsData,
getHumanizedDuration,
getModifiedAboutDetailsData,
determineDetailsValue,
@ -17,16 +18,23 @@ import {
import { mockRuleWithEverything, mockRule } from './all/__mocks__/mock';
import { esFilters } from '../../../../../../../../src/plugins/data/public';
import { Rule } from '../../../containers/detection_engine/rules';
import { AboutStepRule, AboutStepRuleDetails, DefineStepRule, ScheduleStepRule } from './types';
import {
AboutStepRule,
AboutStepRuleDetails,
DefineStepRule,
ScheduleStepRule,
ActionsStepRule,
} from './types';
describe('rule helpers', () => {
describe('getStepsData', () => {
test('returns object with about, define, and schedule step properties formatted', () => {
test('returns object with about, define, schedule and actions step properties formatted', () => {
const {
defineRuleData,
modifiedAboutRuleDetailsData,
aboutRuleData,
scheduleRuleData,
ruleActionsData,
}: GetStepsData = getStepsData({
rule: mockRuleWithEverything('test-id'),
});
@ -98,7 +106,8 @@ describe('rule helpers', () => {
},
],
};
const scheduleRuleStepData = { enabled: true, from: '0s', interval: '5m', isNew: false };
const scheduleRuleStepData = { from: '0s', interval: '5m', isNew: false };
const ruleActionsStepData = { enabled: true, throttle: undefined, isNew: false, actions: [] };
const aboutRuleDataDetailsData = {
note: '# this is some markdown documentation',
description: '24/7',
@ -107,6 +116,7 @@ describe('rule helpers', () => {
expect(defineRuleData).toEqual(defineRuleStepData);
expect(aboutRuleData).toEqual(aboutRuleStepData);
expect(scheduleRuleData).toEqual(scheduleRuleStepData);
expect(ruleActionsData).toEqual(ruleActionsStepData);
expect(modifiedAboutRuleDetailsData).toEqual(aboutRuleDataDetailsData);
});
});
@ -274,7 +284,6 @@ describe('rule helpers', () => {
const result: ScheduleStepRule = getScheduleStepsData(mockedRule);
const expected = {
isNew: false,
enabled: mockedRule.enabled,
interval: mockedRule.interval,
from: '0s',
};
@ -283,6 +292,24 @@ describe('rule helpers', () => {
});
});
describe('getActionsStepsData', () => {
test('returns expected ActionsStepRule rule object', () => {
const mockedRule = {
...mockRule('test-id'),
actions: [],
};
const result: ActionsStepRule = getActionsStepsData(mockedRule);
const expected = {
actions: [],
enabled: mockedRule.enabled,
isNew: false,
throttle: undefined,
};
expect(result).toEqual(expected);
});
});
describe('getModifiedAboutDetailsData', () => {
test('returns object with "note" and "description" being those of passed in rule', () => {
const result: AboutStepRuleDetails = getModifiedAboutDetailsData(

View file

@ -7,8 +7,11 @@
import dateMath from '@elastic/datemath';
import { get } from 'lodash/fp';
import moment from 'moment';
import memoizeOne from 'memoize-one';
import { useLocation } from 'react-router-dom';
import { RuleAlertAction } from '../../../../common/detection_engine/types';
import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions';
import { Filter } from '../../../../../../../../src/plugins/data/public';
import { Rule, RuleType } from '../../../containers/detection_engine/rules';
import { FormData, FormHook, FormSchema } from '../../../shared_imports';
@ -18,6 +21,7 @@ import {
DefineStepRule,
IMitreEnterpriseAttack,
ScheduleStepRule,
ActionsStepRule,
} from './types';
export interface GetStepsData {
@ -25,6 +29,7 @@ export interface GetStepsData {
modifiedAboutRuleDetailsData: AboutStepRuleDetails;
defineRuleData: DefineStepRule;
scheduleRuleData: ScheduleStepRule;
ruleActionsData: ActionsStepRule;
}
export const getStepsData = ({
@ -38,8 +43,29 @@ export const getStepsData = ({
const aboutRuleData: AboutStepRule = getAboutStepsData(rule, detailsView);
const modifiedAboutRuleDetailsData: AboutStepRuleDetails = getModifiedAboutDetailsData(rule);
const scheduleRuleData: ScheduleStepRule = getScheduleStepsData(rule);
const ruleActionsData: ActionsStepRule = getActionsStepsData(rule);
return { aboutRuleData, modifiedAboutRuleDetailsData, defineRuleData, scheduleRuleData };
return {
aboutRuleData,
modifiedAboutRuleDetailsData,
defineRuleData,
scheduleRuleData,
ruleActionsData,
};
};
export const getActionsStepsData = (
rule: Omit<Rule, 'actions'> & { actions: RuleAlertAction[] }
): ActionsStepRule => {
const { enabled, actions = [], meta } = rule;
return {
actions: actions?.map(transformRuleToAlertAction),
isNew: false,
throttle: meta?.throttle,
kibanaSiemAppUrl: meta?.kibanaSiemAppUrl,
enabled,
};
};
export const getDefineStepsData = (rule: Rule): DefineStepRule => ({
@ -60,12 +86,11 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({
});
export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => {
const { enabled, interval, from } = rule;
const { interval, from } = rule;
const fromHumanizedValue = getHumanizedDuration(from, interval);
return {
isNew: false,
enabled,
interval,
from: fromHumanizedValue,
};
@ -200,3 +225,46 @@ export const redirectToDetections = (
isAuthenticated != null &&
hasEncryptionKey != null &&
(!isSignalIndexExists || !isAuthenticated || !hasEncryptionKey);
export const getActionMessageRuleParams = (ruleType: RuleType): string[] => {
const commonRuleParamsKeys = [
'id',
'name',
'description',
'false_positives',
'rule_id',
'max_signals',
'risk_score',
'output_index',
'references',
'severity',
'timeline_id',
'timeline_title',
'threat',
'type',
'version',
// 'lists',
];
const ruleParamsKeys = [
...commonRuleParamsKeys,
...(isMlRule(ruleType)
? ['anomaly_threshold', 'machine_learning_job_id']
: ['index', 'filters', 'language', 'query', 'saved_id']),
].sort();
return ruleParamsKeys;
};
export const getActionMessageParams = memoizeOne((ruleType: RuleType | undefined): string[] => {
if (!ruleType) {
return [];
}
const actionMessageRuleParams = getActionMessageRuleParams(ruleType);
return [
'state.signals_count',
'{context.results_link}',
...actionMessageRuleParams.map(param => `context.rule.${param}`),
];
});

View file

@ -306,6 +306,10 @@ export const SCHEDULE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.sc
defaultMessage: 'Schedule rule',
});
export const RULE_ACTIONS = i18n.translate('xpack.siem.detectionEngine.rules.ruleActionsTitle', {
defaultMessage: 'Rule actions',
});
export const DEFINITION = i18n.translate('xpack.siem.detectionEngine.rules.stepDefinitionTitle', {
defaultMessage: 'Definition',
});
@ -318,6 +322,10 @@ export const SCHEDULE = i18n.translate('xpack.siem.detectionEngine.rules.stepSch
defaultMessage: 'Schedule',
});
export const ACTIONS = i18n.translate('xpack.siem.detectionEngine.rules.stepActionsTitle', {
defaultMessage: 'Actions',
});
export const OPTIONAL_FIELD = i18n.translate(
'xpack.siem.detectionEngine.rules.optionalFieldDescription',
{

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertAction } from '../../../../../../../plugins/alerting/common';
import { RuleAlertAction } from '../../../../common/detection_engine/types';
import { Filter } from '../../../../../../../../src/plugins/data/common';
import { RuleType } from '../../../containers/detection_engine/rules/types';
import { FieldValueQueryBar } from './components/query_bar';
@ -27,6 +29,7 @@ export enum RuleStep {
defineRule = 'define-rule',
aboutRule = 'about-rule',
scheduleRule = 'schedule-rule',
ruleActions = 'rule-actions',
}
export type RuleStatusType = 'passive' | 'active' | 'valid';
@ -76,12 +79,18 @@ export interface DefineStepRule extends StepRuleData {
}
export interface ScheduleStepRule extends StepRuleData {
enabled: boolean;
interval: string;
from: string;
to?: string;
}
export interface ActionsStepRule extends StepRuleData {
actions: AlertAction[];
enabled: boolean;
kibanaSiemAppUrl?: string;
throttle?: string | null;
}
export interface DefineStepRuleJson {
anomaly_threshold?: number;
index?: string[];
@ -108,16 +117,18 @@ export interface AboutStepRuleJson {
}
export interface ScheduleStepRuleJson {
enabled: boolean;
interval: string;
from: string;
to?: string;
meta?: unknown;
}
export type MyRule = Omit<DefineStepRule & ScheduleStepRule & AboutStepRule, 'isNew'> & {
immutable: boolean;
};
export interface ActionsStepRuleJson {
actions: RuleAlertAction[];
enabled: boolean;
throttle?: string | null;
meta?: unknown;
}
export interface IMitreAttack {
id: string;

View file

@ -18,6 +18,9 @@ export {
useForm,
ValidationFunc,
} from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export { Field } from '../../../../../src/plugins/es_ui_shared/static/forms/components';
export {
Field,
SelectField,
} from '../../../../../src/plugins/es_ui_shared/static/forms/components';
export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers';
export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types';

View file

@ -39,7 +39,7 @@ describe('buildSignalsSearchQuery', () => {
{
range: {
'@timestamp': {
gte: from,
gt: from,
lte: to,
},
},

View file

@ -30,7 +30,7 @@ export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignal
{
range: {
'@timestamp': {
gte: from,
gt: from,
lte: to,
},
},

View file

@ -42,7 +42,7 @@ describe('createNotifications', () => {
const action = {
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
params: { message: 'Rule generated {{state.signalsCount}} signals' },
params: { message: 'Rule generated {{state.signals_count}} signals' },
action_type_id: '.slack',
};
await createNotifications({

View file

@ -21,7 +21,7 @@ interface GetSignalsCount {
ruleAlertId: string;
ruleId: string;
index: string;
kibanaUrl: string | undefined;
kibanaSiemAppUrl: string | undefined;
callCluster: NotificationExecutorOptions['services']['callCluster'];
}
@ -32,7 +32,7 @@ export const getSignalsCount = async ({
ruleId,
index,
callCluster,
kibanaUrl = '',
kibanaSiemAppUrl = '',
}: GetSignalsCount): Promise<SignalsCountResults> => {
const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from);
const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to);
@ -53,7 +53,7 @@ export const getSignalsCount = async ({
const result = await callCluster('count', query);
const resultsLink = getNotificationResultsLink({
baseUrl: kibanaUrl,
kibanaSiemAppUrl: `${kibanaSiemAppUrl}`,
id: ruleAlertId,
from: fromInMs,
to: toInMs,

View file

@ -127,7 +127,7 @@ describe('rules_notification_alert_type', () => {
expect(alertInstanceFactoryMock).toHaveBeenCalled();
expect(alertInstanceMock.replaceState).toHaveBeenCalledWith(
expect.objectContaining({ signalsCount: 10 })
expect.objectContaining({ signals_count: 10 })
);
expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith(
'default',

View file

@ -40,14 +40,14 @@ export const rulesNotificationAlertType = ({
}
const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes;
const ruleParams = { ...ruleAlertParams, name: ruleName };
const ruleParams = { ...ruleAlertParams, name: ruleName, id: ruleAlertSavedObject.id };
const { signalsCount, resultsLink } = await getSignalsCount({
from: previousStartedAt ?? `now-${ruleParams.interval}`,
to: startedAt,
index: ruleParams.outputIndex,
ruleId: ruleParams.ruleId!,
kibanaUrl: ruleAlertParams.meta?.kibanaUrl as string,
kibanaSiemAppUrl: ruleAlertParams.meta?.kibanaSiemAppUrl as string,
ruleAlertId: ruleAlertSavedObject.id,
callCluster: services.callCluster,
});

View file

@ -4,11 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mapKeys, snakeCase } from 'lodash/fp';
import { AlertInstance } from '../../../../../../../plugins/alerting/server';
import { RuleTypeParams } from '../types';
type NotificationRuleTypeParams = RuleTypeParams & {
name: string;
id: string;
};
interface ScheduleNotificationActions {
@ -26,9 +28,9 @@ export const scheduleNotificationActions = ({
}: ScheduleNotificationActions): AlertInstance =>
alertInstance
.replaceState({
signalsCount,
signals_count: signalsCount,
})
.scheduleActions('default', {
resultsLink,
rule: ruleParams,
results_link: resultsLink,
rule: mapKeys(snakeCase, ruleParams),
});

View file

@ -88,7 +88,7 @@ describe('updateNotifications', () => {
const action = {
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
params: { message: 'Rule generated {{state.signalsCount}} signals' },
params: { message: 'Rule generated {{state.signals_count}} signals' },
action_type_id: '.slack',
};
await updateNotifications({

View file

@ -9,7 +9,7 @@ import { getNotificationResultsLink } from './utils';
describe('utils', () => {
it('getNotificationResultsLink', () => {
const resultLink = getNotificationResultsLink({
baseUrl: 'http://localhost:5601',
kibanaSiemAppUrl: 'http://localhost:5601/app/siem',
id: 'notification-id',
from: '00000',
to: '1111',

View file

@ -5,14 +5,14 @@
*/
export const getNotificationResultsLink = ({
baseUrl,
kibanaSiemAppUrl,
id,
from,
to,
}: {
baseUrl: string;
kibanaSiemAppUrl: string;
id: string;
from: string;
to: string;
}) =>
`${baseUrl}/app/siem#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`;
`${kibanaSiemAppUrl}#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`;

View file

@ -335,7 +335,7 @@ export const createRuleWithActionsRequest = () => {
{
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
params: { message: 'Rule generated {{state.signalsCount}} signals' },
params: { message: 'Rule generated {{state.signals_count}} signals' },
action_type_id: '.slack',
},
],
@ -668,7 +668,8 @@ export const getNotificationResult = (): RuleNotificationAlertType => ({
{
actionTypeId: '.slack',
params: {
message: 'Rule generated {{state.signalsCount}} signals\n\n{{rule.name}}\n{{resultsLink}}',
message:
'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}',
},
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',

View file

@ -214,13 +214,14 @@ export const signalRulesAlertType = ({
const notificationRuleParams = {
...ruleParams,
name,
id: savedObject.id,
};
const { signalsCount, resultsLink } = await getSignalsCount({
from: `now-${interval}`,
to: 'now',
index: ruleParams.outputIndex,
ruleId: ruleParams.ruleId!,
kibanaUrl: meta?.kibanaUrl as string,
kibanaSiemAppUrl: meta.kibanaSiemAppUrl as string,
ruleAlertId: savedObject.id,
callCluster: services.callCluster,
});