Skip to content
This repository has been archived by the owner on Mar 31, 2024. It is now read-only.

Commit

Permalink
[Security Solution][Detections] EQL Validation (elastic#77493) (elast…
Browse files Browse the repository at this point in the history
…ic#79338)

* WIP: Adding new route for EQL Validation

This is mostly boilerplate with some rough parameter definitions; the
actual implementation of the validation is going to live in our
validateEql function.

A few tests are failing as the mocks haven't yet been implemented, I
need to see the shape of the responses first.

* Cherry-pick Marshall's EQL types

* Implements actual EQL validation

* Performs an EQL search
* filters out non-parsing errors, and returns what remains in the
  response
* Adds mocks for empty EQL responses (we don't yet have a need for
  mocked data, but we will when we unit-test validateEql)

* Adds validation calls to the EQL form input

* Adds EQL Validation response schema,mocks,tests
* Adds frontend function to call our validation endpoint
* Adds hook, useEqlValidation, to call the above function and return
  state
* Adds labels/help text for EQL Query bar
* EqlQueryBar consumes useEqlValidation and marks the field as invalid,
  but does not yet report errors.

* Do not call the validation API if query is not present

This causes a broader error that results in a 400 response; we can (and
do) handle the case of a blank query in the form itself.

* Remove EQL Help Text

It doesn't add any information for the user, and it currently looks bad
when combined with validation errors.

* Flesh out and use our popover for displaying validation errors

* Fixes issue where old errors were persisted after the user had made
  modifications

* Include verification_exception errors as validation errors

These include errors related to index fields and mappings.

* Generalize our validation helpers

We're concerned with validation errors; the source of those errors is an
implementation detail of these functions.

* Move error popover and EQL reference link to footer

This more closely resembles the new Eui Markdown editor, which places
errors and doc links in a footer.

* Fix jest tests following additional prop

* Add icon for EQL Rule card

* Fixes existing EqlQueryBar tests

These were broken by our use of useAppToasts and the EUI theme.

* Add unit tests around error rendering on EQL Query Bar

* Add tests for ErrorPopover

* Remove unused schema type

Decode doesn't do any additional processing, so we can use t.TypeOf here
(the default for buildRouteValidation).

* Remove duplicated header

* Use ignore parameter to prevent EQL validations from logging errors

Without `ignore: [400]` the ES client will log errors and then throw
them. We can catch the error, but the log is undesirable.

This updates the query to use the ignore parameter, along with updating
the validation logic to work with the updated response.

Adds some mocks and tests around these responses and helpers, since
these will exist independent of the validation implementation.

* Include mapping_exceptions during EQL query validation

These include errors for inaccessible indexes, which should be useful to
the rule writer in writing their EQL query.

* Display toast messages for non-validation messages

* fix type errors

This type was renamed.

* Do not request data in our validation request

By not having the cluster retrieve/send any data, this should saves us
a few CPU cycles.

* Move EQL validation to an async form validator

Rather than invoking a custom validation hook (useEqlValidation) at custom times (onBlur) in our EqlQueryBar
component, we can instead move this functionality to a form validation
function and have it be invoked automatically by our form when values
change. However, because we still need to handle the validation messages
slightly differently (place them in a popover as opposed to an
EuiFormRow), we also need custom error retrieval in the form of
getValidationResults.

After much pain, it was determined that the default behavior of
_.debounce does not work with async validator functions, as a debounced
call will not "wait" for the eventual invocation but will instead return
the most recently resolved value. This leads to stale validation
results and terrible UX, so I wrote a custom function (debounceAsync)
that behaves like we want/need; see tests for details.

* Invalidate our query field when index patterns change

Since EQL rules actually validate against the relevant indexes, changing
said indexes should invalidate/revalidate the query.

With the form lib, this is beautifully simple :)

* Set a min-height on our EQL textarea

* Remove unused prop from EqlQueryBar

Index corresponds to the value from the index field; now that our EQL
validation is performed by the form we have no need for it here.

* Update EQL overview link to point to elasticsearch docs

Adds an entry in our doclinks service, and uses that.

* Remove unused prop from stale tests

* Update docLinks documentation with new EQL link

* Fix bug where saved query rules had no type selected on Edit

* Wait for kibana requests to complete before moving between rule tabs

With our new async validation, a user can quickly navigate away from the
Definition tab before the validation has completed, resulting in the
form being invalidated. Any subsequent user actions cause the form to
correct itself, but until I can find a better solution here this really
just gives the validation time to complete and sidesteps the issue.
  • Loading branch information
rylnd authored Oct 2, 2020
1 parent 87d8338 commit 9c99876
Show file tree
Hide file tree
Showing 41 changed files with 1,075 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ readonly links: {
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ export interface DocLinksStart
| --- | --- | --- |
| [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | <code>string</code> | |
| [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | <code>string</code> | |
| [links](./kibana-plugin-core-public.doclinksstart.links.md) | <code>{</code><br/><code> readonly dashboard: {</code><br/><code> readonly drilldowns: string;</code><br/><code> readonly drilldownsTriggerPicker: string;</code><br/><code> readonly urlDrilldownTemplateSyntax: string;</code><br/><code> readonly urlDrilldownVariables: string;</code><br/><code> };</code><br/><code> readonly filebeat: {</code><br/><code> readonly base: string;</code><br/><code> readonly installation: string;</code><br/><code> readonly configuration: string;</code><br/><code> readonly elasticsearchOutput: string;</code><br/><code> readonly startup: string;</code><br/><code> readonly exportedFields: string;</code><br/><code> };</code><br/><code> readonly auditbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly metricbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly heartbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly logstash: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly functionbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly winlogbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly aggs: {</code><br/><code> readonly date_histogram: string;</code><br/><code> readonly date_range: string;</code><br/><code> readonly filter: string;</code><br/><code> readonly filters: string;</code><br/><code> readonly geohash_grid: string;</code><br/><code> readonly histogram: string;</code><br/><code> readonly ip_range: string;</code><br/><code> readonly range: string;</code><br/><code> readonly significant_terms: string;</code><br/><code> readonly terms: string;</code><br/><code> readonly avg: string;</code><br/><code> readonly avg_bucket: string;</code><br/><code> readonly max_bucket: string;</code><br/><code> readonly min_bucket: string;</code><br/><code> readonly sum_bucket: string;</code><br/><code> readonly cardinality: string;</code><br/><code> readonly count: string;</code><br/><code> readonly cumulative_sum: string;</code><br/><code> readonly derivative: string;</code><br/><code> readonly geo_bounds: string;</code><br/><code> readonly geo_centroid: string;</code><br/><code> readonly max: string;</code><br/><code> readonly median: string;</code><br/><code> readonly min: string;</code><br/><code> readonly moving_avg: string;</code><br/><code> readonly percentile_ranks: string;</code><br/><code> readonly serial_diff: string;</code><br/><code> readonly std_dev: string;</code><br/><code> readonly sum: string;</code><br/><code> readonly top_hits: string;</code><br/><code> };</code><br/><code> readonly scriptedFields: {</code><br/><code> readonly scriptFields: string;</code><br/><code> readonly scriptAggs: string;</code><br/><code> readonly painless: string;</code><br/><code> readonly painlessApi: string;</code><br/><code> readonly painlessSyntax: string;</code><br/><code> readonly luceneExpressions: string;</code><br/><code> };</code><br/><code> readonly indexPatterns: {</code><br/><code> readonly loadingData: string;</code><br/><code> readonly introduction: string;</code><br/><code> };</code><br/><code> readonly addData: string;</code><br/><code> readonly kibana: string;</code><br/><code> readonly siem: {</code><br/><code> readonly guide: string;</code><br/><code> readonly gettingStarted: string;</code><br/><code> };</code><br/><code> readonly query: {</code><br/><code> readonly luceneQuerySyntax: string;</code><br/><code> readonly queryDsl: string;</code><br/><code> readonly kueryQuerySyntax: string;</code><br/><code> };</code><br/><code> readonly date: {</code><br/><code> readonly dateMath: string;</code><br/><code> };</code><br/><code> readonly management: Record&lt;string, string&gt;;</code><br/><code> readonly visualize: Record&lt;string, string&gt;;</code><br/><code> }</code> | |
| [links](./kibana-plugin-core-public.doclinksstart.links.md) | <code>{</code><br/><code> readonly dashboard: {</code><br/><code> readonly drilldowns: string;</code><br/><code> readonly drilldownsTriggerPicker: string;</code><br/><code> readonly urlDrilldownTemplateSyntax: string;</code><br/><code> readonly urlDrilldownVariables: string;</code><br/><code> };</code><br/><code> readonly filebeat: {</code><br/><code> readonly base: string;</code><br/><code> readonly installation: string;</code><br/><code> readonly configuration: string;</code><br/><code> readonly elasticsearchOutput: string;</code><br/><code> readonly startup: string;</code><br/><code> readonly exportedFields: string;</code><br/><code> };</code><br/><code> readonly auditbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly metricbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly heartbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly logstash: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly functionbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly winlogbeat: {</code><br/><code> readonly base: string;</code><br/><code> };</code><br/><code> readonly aggs: {</code><br/><code> readonly date_histogram: string;</code><br/><code> readonly date_range: string;</code><br/><code> readonly filter: string;</code><br/><code> readonly filters: string;</code><br/><code> readonly geohash_grid: string;</code><br/><code> readonly histogram: string;</code><br/><code> readonly ip_range: string;</code><br/><code> readonly range: string;</code><br/><code> readonly significant_terms: string;</code><br/><code> readonly terms: string;</code><br/><code> readonly avg: string;</code><br/><code> readonly avg_bucket: string;</code><br/><code> readonly max_bucket: string;</code><br/><code> readonly min_bucket: string;</code><br/><code> readonly sum_bucket: string;</code><br/><code> readonly cardinality: string;</code><br/><code> readonly count: string;</code><br/><code> readonly cumulative_sum: string;</code><br/><code> readonly derivative: string;</code><br/><code> readonly geo_bounds: string;</code><br/><code> readonly geo_centroid: string;</code><br/><code> readonly max: string;</code><br/><code> readonly median: string;</code><br/><code> readonly min: string;</code><br/><code> readonly moving_avg: string;</code><br/><code> readonly percentile_ranks: string;</code><br/><code> readonly serial_diff: string;</code><br/><code> readonly std_dev: string;</code><br/><code> readonly sum: string;</code><br/><code> readonly top_hits: string;</code><br/><code> };</code><br/><code> readonly scriptedFields: {</code><br/><code> readonly scriptFields: string;</code><br/><code> readonly scriptAggs: string;</code><br/><code> readonly painless: string;</code><br/><code> readonly painlessApi: string;</code><br/><code> readonly painlessSyntax: string;</code><br/><code> readonly luceneExpressions: string;</code><br/><code> };</code><br/><code> readonly indexPatterns: {</code><br/><code> readonly loadingData: string;</code><br/><code> readonly introduction: string;</code><br/><code> };</code><br/><code> readonly addData: string;</code><br/><code> readonly kibana: string;</code><br/><code> readonly siem: {</code><br/><code> readonly guide: string;</code><br/><code> readonly gettingStarted: string;</code><br/><code> };</code><br/><code> readonly query: {</code><br/><code> readonly eql: string;</code><br/><code> readonly luceneQuerySyntax: string;</code><br/><code> readonly queryDsl: string;</code><br/><code> readonly kueryQuerySyntax: string;</code><br/><code> };</code><br/><code> readonly date: {</code><br/><code> readonly dateMath: string;</code><br/><code> };</code><br/><code> readonly management: Record&lt;string, string&gt;;</code><br/><code> readonly visualize: Record&lt;string, string&gt;;</code><br/><code> }</code> | |

2 changes: 2 additions & 0 deletions src/core/public/doc_links/doc_links_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class DocLinksService {
gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`,
},
query: {
eql: `${ELASTICSEARCH_DOCS}eql.html`,
luceneQuerySyntax: `${ELASTICSEARCH_DOCS}query-dsl-query-string-query.html#query-string-syntax`,
queryDsl: `${ELASTICSEARCH_DOCS}query-dsl.html`,
kueryQuerySyntax: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kuery-query.html`,
Expand Down Expand Up @@ -228,6 +229,7 @@ export interface DocLinksStart {
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
Expand Down
1 change: 1 addition & 0 deletions src/core/public/public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ export interface DocLinksStart {
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const DETECTION_ENGINE_PREPACKAGED_URL = `${DETECTION_ENGINE_RULES_URL}/p
export const DETECTION_ENGINE_PRIVILEGES_URL = `${DETECTION_ENGINE_URL}/privileges`;
export const DETECTION_ENGINE_INDEX_URL = `${DETECTION_ENGINE_URL}/index`;
export const DETECTION_ENGINE_TAGS_URL = `${DETECTION_ENGINE_URL}/tags`;
export const DETECTION_ENGINE_EQL_VALIDATION_URL = `${DETECTION_ENGINE_URL}/validate_eql`;
export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/_find_statuses`;
export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { EqlValidationSchema } from './eql_validation_schema';

export const getEqlValidationSchemaMock = (): EqlValidationSchema => ({
index: ['index-123'],
query: 'process where process.name == "regsvr32.exe"',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';

import { exactCheck } from '../../../exact_check';
import { foldLeftRight, getPaths } from '../../../test_utils';
import { eqlValidationSchema, EqlValidationSchema } from './eql_validation_schema';
import { getEqlValidationSchemaMock } from './eql_validation_schema.mock';

describe('EQL validation schema', () => {
it('requires a value for index', () => {
const payload = {
...getEqlValidationSchemaMock(),
index: undefined,
};
const decoded = eqlValidationSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);

expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "index"',
]);
expect(message.schema).toEqual({});
});

it('requires a value for query', () => {
const payload = {
...getEqlValidationSchemaMock(),
query: undefined,
};
const decoded = eqlValidationSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);

expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "query"',
]);
expect(message.schema).toEqual({});
});

it('validates a payload with index and query', () => {
const payload = getEqlValidationSchemaMock();
const decoded = eqlValidationSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
const expected: EqlValidationSchema = {
index: ['index-123'],
query: 'process where process.name == "regsvr32.exe"',
};

expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expected);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* 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 * as t from 'io-ts';

import { index, query } from '../common/schemas';

export const eqlValidationSchema = t.exact(
t.type({
index,
query,
})
);

export type EqlValidationSchema = t.TypeOf<typeof eqlValidationSchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* 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 { EqlValidationSchema } from './eql_validation_schema';

export const getEqlValidationResponseMock = (): EqlValidationSchema => ({
valid: false,
errors: ['line 3:52: token recognition error at: '],
});

export const getValidEqlValidationResponseMock = (): EqlValidationSchema => ({
valid: true,
errors: [],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';

import { exactCheck } from '../../../exact_check';
import { foldLeftRight, getPaths } from '../../../test_utils';
import { getEqlValidationResponseMock } from './eql_validation_schema.mock';
import { eqlValidationSchema } from './eql_validation_schema';

describe('EQL validation response schema', () => {
it('validates a typical response', () => {
const payload = getEqlValidationResponseMock();
const decoded = eqlValidationSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);

expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getEqlValidationResponseMock());
});

it('invalidates a response with extra properties', () => {
const payload = { ...getEqlValidationResponseMock(), extra: 'nope' };
const decoded = eqlValidationSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);

expect(getPaths(left(message.errors))).toEqual(['invalid keys "extra"']);
expect(message.schema).toEqual({});
});

it('invalidates a response with missing properties', () => {
const payload = { ...getEqlValidationResponseMock(), valid: undefined };
const decoded = eqlValidationSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);

expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "valid"',
]);
expect(message.schema).toEqual({});
});

it('invalidates a response with properties of the wrong type', () => {
const payload = { ...getEqlValidationResponseMock(), errors: 'should be an array' };
const decoded = eqlValidationSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);

expect(getPaths(left(message.errors))).toEqual([
'Invalid value "should be an array" supplied to "errors"',
]);
expect(message.schema).toEqual({});
});
});
Original file line number Diff line number Diff line change
@@ -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 * as t from 'io-ts';

export const eqlValidationSchema = t.exact(
t.type({
valid: t.boolean,
errors: t.array(t.string),
})
);

export type EqlValidationSchema = t.TypeOf<typeof eqlValidationSchema>;
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export const hasNestedEntry = (entries: EntriesArray): boolean => {

export const isEqlRule = (ruleType: Type | undefined): boolean => ruleType === 'eql';
export const isThresholdRule = (ruleType: Type | undefined): boolean => ruleType === 'threshold';
export const isQueryRule = (ruleType: Type | undefined): boolean => ruleType === 'query';
export const isQueryRule = (ruleType: Type | undefined): boolean =>
ruleType === 'query' || ruleType === 'saved_query';
export const isThreatMatchRule = (ruleType: Type): boolean => ruleType === 'threat_match';
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ import {
goToScheduleStepTab,
waitForTheRuleToBeExecuted,
} from '../tasks/create_new_rule';
import { saveEditedRule } from '../tasks/edit_rule';
import { saveEditedRule, waitForKibana } from '../tasks/edit_rule';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { loginAndWaitForPageWithoutDateRange } from '../tasks/login';
import { refreshPage } from '../tasks/security_header';
Expand Down Expand Up @@ -290,6 +290,7 @@ describe('Custom detection rules deletion and edition', () => {
context('Edition', () => {
it('Allows a rule to be edited', () => {
editFirstRule();
waitForKibana();

// expect define step to populate
cy.get(CUSTOM_QUERY_INPUT).should('have.text', existingRule.customQuery);
Expand Down
Loading

0 comments on commit 9c99876

Please sign in to comment.