Skip to content

Commit

Permalink
[Security Solution] [Detections] EQL Rule Creation (#76831)
Browse files Browse the repository at this point in the history
* Use existing predicate helper to avoid hardcoded strings

* Render our field components with React.createElement

Without this, we get some bad behaviors:
* Cannot use React.memo'd components
* Cannot switch between UseField components (causes a "change in the
  order of hooks" error from React)

* WIP: EQL Rules can be created

WIP because: they're probably not treated well in the UI, and they're certainly not
going to execute properly, and there are no tests.

* Add unit tests for changes to schema + helpers

* Add unit tests for new EQL query input component

It's mostly just a glorified textarea for now.

* Add integration test for EQL Rule creation

* Does not assert the query language, as that is not displayed on Rule
  Details
* Does not exercise rule execution

* Use predicate helper

* Throw an error if an EQL Rule is executed

This is to prevent undefined behavior until EQL execution is
implemented.

* Fix failing tests

I changed the default value for the form field mock from an array to a
string; this fixes the few tests that were relying on it being an array.

* Audit our rule statements/switches

I made a pass through our treatment of RuleType to verify that EQL rules
would be treated appropriately. Since the default/fallthrough case is
typically the Query rule, and since this rule has the same
attributes/behavior as the new EQL rule, not much had to change here.

I converted a few if statements to exhaustive switches where possible,
and used predicate helpers in places where it was not.

* Add tests around use of custom components with UseField

There was an issue previously where memoized components would not work;
these are primarily regression tests covering that use case.

* Fix typo

* Add keys to UseField to ensure unmount

When swapping between the Custom Query and EQL rule types, we want to
ensure that the corresponding input component coming from UseField fully
unmounts and remounts with the new component.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
rylnd and elasticmachine committed Sep 15, 2020
1 parent 719cda5 commit 7f3ec3f
Show file tree
Hide file tree
Showing 39 changed files with 618 additions and 119 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect } from 'react';
import React, { useEffect, FunctionComponent } from 'react';
import { act } from 'react-dom/test-utils';

import { registerTestBed, TestBed } from '../shared_imports';
Expand Down Expand Up @@ -237,4 +237,64 @@ describe('<UseField />', () => {
expect(serializer).not.toBeCalled();
});
});

describe('custom components', () => {
interface MyForm {
name: string;
}

let formHook: FormHook<MyForm> | null = null;

beforeEach(() => {
formHook = null;
});

const onFormHook = (_form: FormHook<MyForm>) => {
formHook = _form;
};

const TestComp = ({
component,
onForm,
}: {
component: FunctionComponent<any>;
onForm: (form: FormHook<MyForm>) => void;
}) => {
const { form } = useForm<MyForm>();

useEffect(() => {
onForm(form);
}, [onForm, form]);

return (
<Form form={form}>
<UseField path="name" defaultValue="myName" component={component} />
</Form>
);
};

it('allows function components', () => {
const Component = () => <textarea data-test-subj="function-component" />;
const setup = registerTestBed(TestComp, {
defaultProps: { onForm: onFormHook, component: Component },
memoryRouter: { wrapComponent: false },
});
const testBed = setup() as TestBed;

expect(testBed.exists('function-component')).toEqual(true);
expect(formHook?.getFormData()).toEqual({ name: 'myName' });
});

it('allows memoized function components', () => {
const Component = React.memo(() => <textarea data-test-subj="memoized-component" />);
const setup = registerTestBed(TestComp, {
defaultProps: { onForm: onFormHook, component: Component },
memoryRouter: { wrapComponent: false },
});
const testBed = setup() as TestBed;

expect(testBed.exists('memoized-component')).toEqual(true);
expect(formHook?.getFormData()).toEqual({ name: 'myName' });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function UseFieldComp<T = unknown>(props: Props<T>) {
} = props;

const form = useFormContext();
const componentToRender = component ?? 'input';
const ComponentToRender = component ?? 'input';
// For backward compatibility we merge the "componentProps" prop into the "rest"
const propsToForward =
componentProps !== undefined ? { ...componentProps, ...rest } : { ...rest };
Expand Down Expand Up @@ -91,9 +91,9 @@ function UseFieldComp<T = unknown>(props: Props<T>) {
return children!(field);
}

if (componentToRender === 'input') {
if (ComponentToRender === 'input') {
return (
<input
<ComponentToRender
type={field.type}
onChange={field.onChange}
value={(field.value as unknown) as string}
Expand All @@ -102,7 +102,7 @@ function UseFieldComp<T = unknown>(props: Props<T>) {
);
}

return componentToRender({ field, ...propsToForward });
return <ComponentToRender {...{ field, ...propsToForward }} />;
}

export const UseField = React.memo(UseFieldComp) as typeof UseFieldComp;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export type Query = t.TypeOf<typeof query>;
export const queryOrUndefined = t.union([query, t.undefined]);
export type QueryOrUndefined = t.TypeOf<typeof queryOrUndefined>;

export const language = t.keyof({ kuery: null, lucene: null });
export const language = t.keyof({ eql: null, kuery: null, lucene: null });
export type Language = t.TypeOf<typeof language>;

export const languageOrUndefined = t.union([language, t.undefined]);
Expand Down Expand Up @@ -294,6 +294,7 @@ export const toOrUndefined = t.union([to, t.undefined]);
export type ToOrUndefined = t.TypeOf<typeof toOrUndefined>;

export const type = t.keyof({
eql: null,
machine_learning: null,
query: null,
saved_query: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { isMlRule } from '../../../machine_learning/helpers';
import { isThresholdRule } from '../../utils';
import { AddPrepackagedRulesSchema } from './add_prepackaged_rules_schema';

export const validateAnomalyThreshold = (rule: AddPrepackagedRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.anomaly_threshold == null) {
return ['when "type" is "machine_learning" anomaly_threshold is required'];
} else {
Expand All @@ -19,7 +21,7 @@ export const validateAnomalyThreshold = (rule: AddPrepackagedRulesSchema): strin
};

export const validateQuery = (rule: AddPrepackagedRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.query != null) {
return ['when "type" is "machine_learning", "query" cannot be set'];
} else {
Expand All @@ -31,7 +33,7 @@ export const validateQuery = (rule: AddPrepackagedRulesSchema): string[] => {
};

export const validateLanguage = (rule: AddPrepackagedRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.language != null) {
return ['when "type" is "machine_learning", "language" cannot be set'];
} else {
Expand All @@ -55,7 +57,7 @@ export const validateSavedId = (rule: AddPrepackagedRulesSchema): string[] => {
};

export const validateMachineLearningJobId = (rule: AddPrepackagedRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.machine_learning_job_id == null) {
return ['when "type" is "machine_learning", "machine_learning_job_id" is required'];
} else {
Expand Down Expand Up @@ -93,7 +95,7 @@ export const validateTimelineTitle = (rule: AddPrepackagedRulesSchema): string[]
};

export const validateThreshold = (rule: AddPrepackagedRulesSchema): string[] => {
if (rule.type === 'threshold') {
if (isThresholdRule(rule.type)) {
if (!rule.threshold) {
return ['when "type" is "threshold", "threshold" is required'];
} else if (rule.threshold.value <= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { isMlRule } from '../../../machine_learning/helpers';
import { isThresholdRule } from '../../utils';
import { CreateRulesSchema } from './create_rules_schema';

export const validateAnomalyThreshold = (rule: CreateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.anomaly_threshold == null) {
return ['when "type" is "machine_learning" anomaly_threshold is required'];
} else {
Expand All @@ -19,7 +21,7 @@ export const validateAnomalyThreshold = (rule: CreateRulesSchema): string[] => {
};

export const validateQuery = (rule: CreateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.query != null) {
return ['when "type" is "machine_learning", "query" cannot be set'];
} else {
Expand All @@ -31,7 +33,7 @@ export const validateQuery = (rule: CreateRulesSchema): string[] => {
};

export const validateLanguage = (rule: CreateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.language != null) {
return ['when "type" is "machine_learning", "language" cannot be set'];
} else {
Expand All @@ -55,7 +57,7 @@ export const validateSavedId = (rule: CreateRulesSchema): string[] => {
};

export const validateMachineLearningJobId = (rule: CreateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.machine_learning_job_id == null) {
return ['when "type" is "machine_learning", "machine_learning_job_id" is required'];
} else {
Expand Down Expand Up @@ -93,7 +95,7 @@ export const validateTimelineTitle = (rule: CreateRulesSchema): string[] => {
};

export const validateThreshold = (rule: CreateRulesSchema): string[] => {
if (rule.type === 'threshold') {
if (isThresholdRule(rule.type)) {
if (!rule.threshold) {
return ['when "type" is "threshold", "threshold" is required'];
} else if (rule.threshold.value <= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { isMlRule } from '../../../machine_learning/helpers';
import { isThresholdRule } from '../../utils';
import { ImportRulesSchema } from './import_rules_schema';

export const validateAnomalyThreshold = (rule: ImportRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.anomaly_threshold == null) {
return ['when "type" is "machine_learning" anomaly_threshold is required'];
} else {
Expand All @@ -19,7 +21,7 @@ export const validateAnomalyThreshold = (rule: ImportRulesSchema): string[] => {
};

export const validateQuery = (rule: ImportRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.query != null) {
return ['when "type" is "machine_learning", "query" cannot be set'];
} else {
Expand All @@ -31,7 +33,7 @@ export const validateQuery = (rule: ImportRulesSchema): string[] => {
};

export const validateLanguage = (rule: ImportRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.language != null) {
return ['when "type" is "machine_learning", "language" cannot be set'];
} else {
Expand All @@ -55,7 +57,7 @@ export const validateSavedId = (rule: ImportRulesSchema): string[] => {
};

export const validateMachineLearningJobId = (rule: ImportRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.machine_learning_job_id == null) {
return ['when "type" is "machine_learning", "machine_learning_job_id" is required'];
} else {
Expand Down Expand Up @@ -93,7 +95,7 @@ export const validateTimelineTitle = (rule: ImportRulesSchema): string[] => {
};

export const validateThreshold = (rule: ImportRulesSchema): string[] => {
if (rule.type === 'threshold') {
if (isThresholdRule(rule.type)) {
if (!rule.threshold) {
return ['when "type" is "threshold", "threshold" is required'];
} else if (rule.threshold.value <= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { isMlRule } from '../../../machine_learning/helpers';
import { isThresholdRule } from '../../utils';
import { PatchRulesSchema } from './patch_rules_schema';

export const validateQuery = (rule: PatchRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.query != null) {
return ['when "type" is "machine_learning", "query" cannot be set'];
} else {
Expand All @@ -19,7 +21,7 @@ export const validateQuery = (rule: PatchRulesSchema): string[] => {
};

export const validateLanguage = (rule: PatchRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.language != null) {
return ['when "type" is "machine_learning", "language" cannot be set'];
} else {
Expand Down Expand Up @@ -67,7 +69,7 @@ export const validateId = (rule: PatchRulesSchema): string[] => {
};

export const validateThreshold = (rule: PatchRulesSchema): string[] => {
if (rule.type === 'threshold') {
if (isThresholdRule(rule.type)) {
if (!rule.threshold) {
return ['when "type" is "threshold", "threshold" is required'];
} else if (rule.threshold.value <= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { isMlRule } from '../../../machine_learning/helpers';
import { isThresholdRule } from '../../utils';
import { UpdateRulesSchema } from './update_rules_schema';

export const validateAnomalyThreshold = (rule: UpdateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.anomaly_threshold == null) {
return ['when "type" is "machine_learning" anomaly_threshold is required'];
} else {
Expand All @@ -19,7 +21,7 @@ export const validateAnomalyThreshold = (rule: UpdateRulesSchema): string[] => {
};

export const validateQuery = (rule: UpdateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.query != null) {
return ['when "type" is "machine_learning", "query" cannot be set'];
} else {
Expand All @@ -31,7 +33,7 @@ export const validateQuery = (rule: UpdateRulesSchema): string[] => {
};

export const validateLanguage = (rule: UpdateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.language != null) {
return ['when "type" is "machine_learning", "language" cannot be set'];
} else {
Expand All @@ -55,7 +57,7 @@ export const validateSavedId = (rule: UpdateRulesSchema): string[] => {
};

export const validateMachineLearningJobId = (rule: UpdateRulesSchema): string[] => {
if (rule.type === 'machine_learning') {
if (isMlRule(rule.type)) {
if (rule.machine_learning_job_id == null) {
return ['when "type" is "machine_learning", "machine_learning_job_id" is required'];
} else {
Expand Down Expand Up @@ -103,7 +105,7 @@ export const validateId = (rule: UpdateRulesSchema): string[] => {
};

export const validateThreshold = (rule: UpdateRulesSchema): string[] => {
if (rule.type === 'threshold') {
if (isThresholdRule(rule.type)) {
if (!rule.threshold) {
return ['when "type" is "threshold", "threshold" is required'];
} else if (rule.threshold.value <= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,16 @@ describe('rules_schema', () => {
expect(fields.length).toEqual(2);
});

test('should return two fields for a rule of type "eql"', () => {
const fields = addQueryFields({ type: 'eql' });
expect(fields.length).toEqual(2);
});

test('should return two fields for a rule of type "threshold"', () => {
const fields = addQueryFields({ type: 'threshold' });
expect(fields.length).toEqual(2);
});

test('should return two fields for a rule of type "saved_query"', () => {
const fields = addQueryFields({ type: 'saved_query' });
expect(fields.length).toEqual(2);
Expand Down
Loading

0 comments on commit 7f3ec3f

Please sign in to comment.