Skip to content

Commit

Permalink
[SIEM] Create ML Rules (#58053)
Browse files Browse the repository at this point in the history
* Remove unnecessary linter exceptions

Not sure what was causing issues here, but it's gone now.

* WIP: Simple form to test creation of ML rules

This will be integrated into the regular rule creation workflow, but for
now this simple form should allow us to exercise the full ML rule
workflow.

* WIP: Adds POST to backend, and type/payload changes necessary to make that work

* Simplify logic with Math.min

* WIP: Failed spike of making an http call

* WIP: Hacking together an ML client

The rest of this is going to be easier if I have actual data. For now
this is mostly copy/pasted and simplified ML code. I've hardcoded time
ranges to a period I know has data for a particular job.

* Threading through our new ML Rule params

It's a bummer that we normalize our rule alert params across all rule
types currently, but that's the deal.

* Retrieve our anomalies during rule execution

Next step: generate signals

* WIP: Generate ECS-compatible ML Signals

This uses as much of the existing signal-creation code as possible. I
skipped the search_after stuff for now because it would require us
recreating the anomalies query which we really shouldn't own. For now,
here's how it works:

* Adds a separate branch of the rule executor for machine_learning rules
* In that branch, we call our new bulkCreateMlSignal function
  * This function first transforms the anomaly document into ECS fields
  * We then pass the transformed documents to singleBulkCreate, which
  does the rest
* After both branches, we update the rule's status appropriately.

We need to do some more work on the anomaly transformation, but this
works!

* Extract setting of rule failure to helper function

We were doing this identically in three places.

* Remove unused import

* Define a field for our Rule Type selection

This adds most of the markup and logic to allow an ML rule type to be
selected. We still need to add things like license-checking and
showing/hiding of fields based on type.

* Hide Query Fields when ML is selected

These are still getting set on the form. We'll need to filter these
fields before we send off the data, and not show them on the readonly
display either.

ALso, edit is majorly broken.

* Add input field for anomaly threshold

* Display numberic values in the readonly view of a step

TIL that isEmpty returns false for numbers and other non-iterable
values. I don't think it's exactly what we want here, but until I figure
out the intention this gets our anomalyThreshold showing up without a
separate logic branch here. Removes the unnecessary branch that was
redundant with the 'else' clause.

* Add field for selecting an ML job

This is not the same as the mockups and lacks some functionality, but
it'll allow us to select a job for now.

* Format our new ML Fields when sending them to the server

So that we don't get rejected due to snake case vs camelcase.

* Put back code that respects a rule's schedule

It was previously hardcoded to a time period I knew had anomalies.

* ML fields are optional in our creation step

In that we don't initialize them like we do the query (default) fields.

* Only send along type-specific Rule fields from form

This makes any query- or ML-specific fields optional on a Rule, and
performs some logic on the frontend to group and include these fieldsets
conditionally based on the user's selection. The one place we don't
handle this well is on the readonly view of a completed step in the
rules creation, but we'll address that.

* Rename anomalies query

It's no longer tabular data. If we need that, we can use the ML client.

* Remove spike page with simple form

* Remove unneeded ES option

This response isn't going to HTTP, which is where this option would
matter.

* Fix bulk create logic

I made a happy accident and flipped the logic here, which meant we
weren't capping the signals we created.

* Rename argument

Value is a little more ambiguous than data, here: this is our step data.

* Create Rule form stores all values, but filters by type for use

When sending off to the backend, or displaying on the readonly view, we
inspect which rule type we've currently selected, and filter our form
values appropriately.

* Fix editing of ML fields on Rule Create

We need to inherit the field value from our form on initial render, and
everything works as expected.

* Clear form errors when switching between rule types

Validation errors prevent us from moving to the next step, so it was
previously possible to get an error for Query fields, switch to an ML
rule, and be unable to continue because the form had Query errors.

This also adds a helper for checking whether a ruleType is ML, to
prevent having to change all these references if the type string
changes.

* Validate the selection of an ML Job

* Fix type errors on frontend

According to the types, this is essentially the opposite of formatRule,
so we need to reinflate all potential form values from the rule.

* Don't set defaults for query-specific rules

For ML rules these types should not be included.

* Return ML Fields in Rule responses

This adds these fields to our rule serialization, and then adds
conditional validation around those fields if the rule type is ML.
Conversely, we moved the 'language' and 'query' fields to be
conditionally validated if the rule is a query/saved_query rule.

* Fix editing of ML rules by changing who controls the field values

The source of truth for their state is the parent form object; these
inputs should not have local state.

* Fix type errors related to new ML fields

In adding the new ML fields, some other fields (e.g. `query` and
`index`) that were previously required but implicitly part of Query
Rules are now marked as optional.

Consequently, any downstream code that actually required these fields
started to complain. In general, the fix was to verify that those fields
exist, and throw an error otherwise as to appease the linter.

Runtime-wise, the new ML rules/signals follow a separate code path and
both branches should be unaffected by these changes; the issue is simply
that our conditional types don't work well with Typescript.

* Fix failing route tests

Error message changed.

* Fix integration tests

We were not sending required properties when creating a rule(index and
language).

* Fix non-ML Rule creation

I was accidentally dropping this parameter for our POST payload. Whoops.

* More informative logging during ML signal generation

The messaging diverged from the normal path here because we don't have
index patterns to display. However, we have the rest of the rule
context, and should report it appropriately.

* Prefer keyof for string union types

* Tidy up our new form components

* Type them as React.FCs
* Remove unnecessary use of styled-components

* Prefer destructuring to lodash's omit

* Fix mock params for helper functions

These were updated to take simpler parameters.

* Remove any type

This could have been a boolean all along, whoops

* Fix mock types

* Update outdated tests

These were added on master, but behavior has been changed on my branch.

* Add some tests around our helper function

I need to refactor it, so this is as good a time as any to pin down the
behavior.

* Remove uses of any in favor of actual types

Mainly leverages ML typings instead of our placeholder types. This
required handling a null case in our formatting of anomalies.

* Annotate our anomalies with @timestamp field

We were notably lacking this ECS field in our post-conversion anomalies,
and typescript was rightly complaining about it.

* ml_job_id -> machine_learning_job_id

* PR Feedback

* Stricter threshold type
* More robust date parsing
* More informative log/error messages
* Remove redundant runtime checks

* Cleaning up our new ML types

* Fix types on our Rest types
* Use less ambiguous machineLearningJobId over mlJobId
* Declare our ML params as required keys, and ensure we pass them around
everywhere we might need them (creating, importing, updating rules).

* Use implicit type to avoid the need for a ts-ignore

FormSchema has a very generic index signature such that our
filterRuleFieldsForType helper cannot infer that it has our necessary
rule fields (when in fact it does). By removing the FormSchema hint we
get the actual keys of our schema, and things work as expected.

All other uses of schema continue to work because they're expecting
FormSchema, which is effectively { [key: string]: any }.

* New ML params are not nullable

Rather than setting a null and then never using it, let's just make it
truly optional in terms of default values.

* Query and language are conditional based on rule type

For ML Rules, we don't use them.

* Remove defaulted parameter in API test

We don't need to specify this, and we should continue not to for
backwards compatibility.

* Use explicit types over implicit ones

The concern is that not typing our schemae as FormSchema could break our
form if there are upstream changes. For now, we simply use the
intersection of FormSchema and our generic parameter to satisfy our use
within the function.

* Add integration test for creation of ML Rule

* Add ML fields to route schemae

* threshold and job id are conditional on type
* makes query and language mutually exclusive with above

* Fix router test for creating an ML rule

We were sending invalid parameters.

* Remove null check against index for query rules

We support not having an index here, as getInputIndex will return the
current UI setting if none is specified.

* Add regression test for API compatibility

We were previously able to create a rule without an input index; we
should continue to support that, as verified by this test!

* Respect the index pattern determined at runtime when performing search_after

If a rule does not specify an input index pattern on creation, we use
the current UI default when the rule is evaluated. This ensures that any
subsequent searches use that same index.

We're not currently persisting that runtime index to the generated
signal, but we should.

* Fix type errors in our bulk create tests

We added a new argument, but didn't update the tests.
  • Loading branch information
rylnd authored Mar 19, 2020
1 parent 357ed0e commit a05a612
Show file tree
Hide file tree
Showing 64 changed files with 1,297 additions and 273 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,35 @@

import * as t from 'io-ts';

export const RuleTypeSchema = t.keyof({
query: null,
saved_query: null,
machine_learning: null,
});
export type RuleType = t.TypeOf<typeof RuleTypeSchema>;

export const NewRuleSchema = t.intersection([
t.type({
description: t.string,
enabled: t.boolean,
filters: t.array(t.unknown),
index: t.array(t.string),
interval: t.string,
language: t.string,
name: t.string,
query: t.string,
risk_score: t.number,
severity: t.string,
type: t.union([t.literal('query'), t.literal('saved_query')]),
type: RuleTypeSchema,
}),
t.partial({
anomaly_threshold: t.number,
created_by: t.string,
false_positives: t.array(t.string),
filters: t.array(t.unknown),
from: t.string,
id: t.string,
index: t.array(t.string),
language: t.string,
machine_learning_job_id: t.string,
max_signals: t.number,
query: t.string,
references: t.array(t.string),
rule_id: t.string,
saved_id: t.string,
Expand Down Expand Up @@ -56,32 +65,34 @@ export const RuleSchema = t.intersection([
description: t.string,
enabled: t.boolean,
false_positives: t.array(t.string),
filters: t.array(t.unknown),
from: t.string,
id: t.string,
index: t.array(t.string),
interval: t.string,
immutable: t.boolean,
language: t.string,
name: t.string,
max_signals: t.number,
query: t.string,
references: t.array(t.string),
risk_score: t.number,
rule_id: t.string,
severity: t.string,
tags: t.array(t.string),
type: t.string,
type: RuleTypeSchema,
to: t.string,
threat: t.array(t.unknown),
updated_at: t.string,
updated_by: t.string,
}),
t.partial({
anomaly_threshold: t.number,
filters: t.array(t.unknown),
index: t.array(t.string),
language: t.string,
last_failure_at: t.string,
last_failure_message: t.string,
meta: MetaRule,
machine_learning_job_id: t.string,
output_index: t.string,
query: t.string,
saved_id: t.string,
status: t.string,
status_date: t.string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({

export const mockDefineStepRule = (isNew = false): DefineStepRule => ({
isNew,
ruleType: 'query',
anomalyThreshold: 50,
machineLearningJobId: '',
index: ['filebeat-'],
queryBar: mockQueryBar,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 { EuiFlexGrid, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui';

import { FieldHook } from '../../../../../shared_imports';

interface AnomalyThresholdSliderProps {
field: FieldHook;
}
type Event = React.ChangeEvent<HTMLInputElement>;
type EventArg = Event | React.MouseEvent<HTMLButtonElement>;

export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ field }) => {
const threshold = field.value as number;
const onThresholdChange = useCallback(
(event: EventArg) => {
const thresholdValue = Number((event as Event).target.value);
field.setValue(thresholdValue);
},
[field]
);

return (
<EuiFormRow label={field.label} fullWidth>
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiRange
value={threshold}
onChange={onThresholdChange}
fullWidth
showInput
showRange
showTicks
tickInterval={25}
/>
</EuiFlexItem>
</EuiFlexGrid>
</EuiFormRow>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ setupMock.uiSettings.get.mockImplementation(uiSettingsMock(true));
const mockFilterManager = new FilterManager(setupMock.uiSettings);

const mockQueryBar = {
query: {
query: 'test query',
language: 'kuery',
},
query: 'test query',
filters: [
{
$state: {
Expand Down Expand Up @@ -93,10 +90,7 @@ describe('helpers', () => {
describe('buildQueryBarDescription', () => {
test('returns empty array if no filters, query or savedId exist', () => {
const emptyMockQueryBar = {
query: {
query: '',
language: 'kuery',
},
query: '',
filters: [],
saved_id: '',
};
Expand All @@ -113,10 +107,7 @@ describe('helpers', () => {
test('returns expected array of ListItems when filters exists, but no indexPatterns passed in', () => {
const mockQueryBarWithFilters = {
...mockQueryBar,
query: {
query: '',
language: 'kuery',
},
query: '',
saved_id: '',
};
const result: ListItems[] = buildQueryBarDescription({
Expand All @@ -135,10 +126,7 @@ describe('helpers', () => {
test('returns expected array of ListItems when filters AND indexPatterns exist', () => {
const mockQueryBarWithFilters = {
...mockQueryBar,
query: {
query: '',
language: 'kuery',
},
query: '',
saved_id: '',
};
const result: ListItems[] = buildQueryBarDescription({
Expand Down Expand Up @@ -171,16 +159,13 @@ describe('helpers', () => {
savedId: mockQueryBarWithQuery.saved_id,
});
expect(result[0].title).toEqual(<>{i18n.QUERY_LABEL} </>);
expect(result[0].description).toEqual(<>{mockQueryBarWithQuery.query.query} </>);
expect(result[0].description).toEqual(<>{mockQueryBarWithQuery.query} </>);
});

test('returns expected array of ListItems when "savedId" exists', () => {
const mockQueryBarWithSavedId = {
...mockQueryBar,
query: {
query: '',
language: 'kuery',
},
query: '',
filters: [],
};
const result: ListItems[] = buildQueryBarDescription({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@ export const buildQueryBarDescription = ({
},
];
}
if (!isEmpty(query.query)) {
if (!isEmpty(query)) {
items = [
...items,
{
title: <>{i18n.QUERY_LABEL} </>,
description: <>{query.query} </>,
description: <>{query} </>,
},
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { isEmpty, chunk, get, pick } from 'lodash/fp';
import { isEmpty, chunk, get, pick, isNumber } from 'lodash/fp';
import React, { memo, useState } from 'react';
import styled from 'styled-components';

Expand All @@ -14,7 +14,6 @@ import {
Filter,
esFilters,
FilterManager,
Query,
} from '../../../../../../../../../../src/plugins/data/public';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations';
import { useKibana } from '../../../../../lib/kibana';
Expand Down Expand Up @@ -133,14 +132,14 @@ export const addFilterStateIfNotThere = (filters: Filter[]): Filter[] => {
export const getDescriptionItem = (
field: string,
label: string,
value: unknown,
data: unknown,
filterManager: FilterManager,
indexPatterns?: IIndexPattern
): ListItems[] => {
if (field === 'queryBar') {
const filters = addFilterStateIfNotThere(get('queryBar.filters', value) ?? []);
const query = get('queryBar.query', value) as Query;
const savedId = get('queryBar.saved_id', value);
const filters = addFilterStateIfNotThere(get('queryBar.filters', data) ?? []);
const query = get('queryBar.query.query', data);
const savedId = get('queryBar.saved_id', data);
return buildQueryBarDescription({
field,
filters,
Expand All @@ -150,43 +149,37 @@ export const getDescriptionItem = (
indexPatterns,
});
} else if (field === 'threat') {
const threat: IMitreEnterpriseAttack[] = get(field, value).filter(
const threat: IMitreEnterpriseAttack[] = get(field, data).filter(
(singleThreat: IMitreEnterpriseAttack) => singleThreat.tactic.name !== 'none'
);
return buildThreatDescription({ label, threat });
} else if (field === 'references') {
const urls: string[] = get(field, value);
const urls: string[] = get(field, data);
return buildUrlsDescription(label, urls);
} else if (field === 'falsePositives') {
const values: string[] = get(field, value);
const values: string[] = get(field, data);
return buildUnorderedListArrayDescription(label, field, values);
} else if (Array.isArray(get(field, value))) {
const values: string[] = get(field, value);
} else if (Array.isArray(get(field, data))) {
const values: string[] = get(field, data);
return buildStringArrayDescription(label, field, values);
} else if (field === 'severity') {
const val: string = get(field, value);
const val: string = get(field, data);
return buildSeverityDescription(label, val);
} else if (field === 'riskScore') {
return [
{
title: label,
description: get(field, value),
},
];
} else if (field === 'timeline') {
const timeline = get(field, value) as FieldValueTimeline;
const timeline = get(field, data) as FieldValueTimeline;
return [
{
title: label,
description: timeline.title ?? DEFAULT_TIMELINE_TITLE,
},
];
} else if (field === 'note') {
const val: string = get(field, value);
const val: string = get(field, data);
return buildNoteDescription(label, val);
}
const description: string = get(field, value);
if (!isEmpty(description)) {

const description: string = get(field, data);
if (isNumber(description) || !isEmpty(description)) {
return [
{
title: label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
IIndexPattern,
Filter,
FilterManager,
Query,
} from '../../../../../../../../../../src/plugins/data/public';
import { IMitreEnterpriseAttack } from '../../types';

Expand All @@ -22,7 +21,7 @@ export interface BuildQueryBarDescription {
field: string;
filters: Filter[];
filterManager: FilterManager;
query: Query;
query: string;
savedId: string;
indexPatterns?: IIndexPattern;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSuperSelect, EuiText } from '@elastic/eui';

import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports';
import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs';

const JobDisplay = ({ title, description }: { title: string; description: string }) => (
<>
<strong>{title}</strong>
<EuiText size="xs" color="subdued">
<p>{description}</p>
</EuiText>
</>
);

interface MlJobSelectProps {
field: FieldHook;
}

export const MlJobSelect: React.FC<MlJobSelectProps> = ({ field }) => {
const jobId = field.value as string;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const [isLoading, siemJobs] = useSiemJobs(false);
const handleJobChange = useCallback(
(machineLearningJobId: string) => {
field.setValue(machineLearningJobId);
},
[field]
);

const options = siemJobs.map(job => ({
value: job.id,
inputDisplay: job.id,
dropdownDisplay: <JobDisplay title={job.id} description={job.description} />,
}));

return (
<EuiFormRow fullWidth label={field.label} isInvalid={isInvalid} error={errorMessage}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiSuperSelect
hasDividers
isLoading={isLoading}
onChange={handleJobChange}
options={options}
valueOfSelected={jobId}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import * as i18n from './translations';
export interface FieldValueQueryBar {
filters: Filter[];
query: Query;
saved_id: string | null;
saved_id?: string;
}
interface QueryBarDefineRuleProps {
browserFields: BrowserFields;
Expand Down
Loading

0 comments on commit a05a612

Please sign in to comment.