[8.x] [Security Solution] Add EQL query editable component with EQL options fields (#199115) (#201314)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution] Add EQL query editable component with EQL options
fields (#199115)](https://github.com/elastic/kibana/pull/199115)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Maxim
Palenov","email":"maxim.palenov@elastic.co"},"sourceCommit":{"committedDate":"2024-11-22T07:27:40Z","message":"[Security
Solution] Add EQL query editable component with EQL options fields
(#199115)\n\n**Partially addresses:**
https://github.com/elastic/kibana/issues/171520\r\n\r\n##
Summary\r\n\r\nThis PR adds is built on top of
https://github.com/elastic/kibana/pull/193828 and
https://github.com/elastic/kibana/pull/196948 and adds an EQL Query
editable component with EQL Options fields (`event_category_override`,
`timestamp_field` and `tiebreaker_field`) for Three Way Diff tab's final
edit side of the upgrade prebuilt rule workflow.\r\n\r\n##
Details\r\n\r\nThis PR make a set of changes to make existing EQL Query
bar component easily reusable and type safe when used in forms. In
particular the following was done\r\n\r\n- EQL query bar was wrapped in
`EqlQueryEdit` component with `UseField` inside. It helps to make it
type safe avoiding issues like passing invalid types to `EqlQueryBar`.
`UseField` types component properties as `Record<string, any>` so
potentially any refactoring can break some functionality. For example
code in Timeline passes `DataViewSpec` where `DataViewBase` is expected
while these two types aren't fully compatible.\r\n- Validation was added
directly to `EqlQueryEdit`. Passing field configuration to `UseField`
rewrites field configuration defined in from schema. It leads to cases
when validation is defined in both form schema and as a field
configuration for `UseFields`. Additionally we can reduce reusing
complexity by incapsulating absolutely required validation in
`EqlQueryEdit` component.\r\n- Empty string `tiebreakerField` was
removed in Timelines. `tiebreakerField` is part of EQL options used for
EQL validation. EQL validation endpoint `/internal/search/eql` returns
an error when an empty string provided for `tiebreakerField`. This
problem didn't surface earlier since It looks like EQL options weren't
provided correctly before this PR. Timeline EQL validation requests were
sent without EQL options.\r\n\r\n## How to test\r\n\r\nThe simplest way
to test is via patching installed prebuilt rules via Rule Patch API.
Please follow steps below\r\n\r\n- Ensure the
`prebuiltRulesCustomizationEnabled` feature flag is enabled\r\n- Run
Kibana locally\r\n- Install an EQL prebuilt rule, e.g. `Potential Code
Execution via Postgresql` with rule_id
`2a692072-d78d-42f3-a48a-775677d79c4e`\r\n- Patch the installed rule by
running a query below\r\n\r\n```bash\r\ncurl -X PATCH --user
elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'
-H \"elastic-api-version: 2023-10-31\" -d
'{\"rule_id\":\"2a692072-d78d-42f3-a48a-775677d79c4e\",\"version\":1,\"query\":\"process
where process.name ==
\\\"cmd.exe\\\"\",\"language\":\"eql\",\"event_category_override\":
\"test\",\"timestamp_field\": \"@timestamp\",\"tiebreaker_field\":
\"tiebreaker\"}'
http://localhost:5601/kbn/api/detection_engine/rules\r\n```\r\n\r\n-
Open `Detection Rules (SIEM)` Page -> `Rule Updates` -> click on
`Potential Code Execution via Postgresql` rule -> expand `EQL Query` to
see EQL Query -> press `Edit` button\r\n\r\n## Screenshots\r\n\r\n- EQL
Query in Prebuilt Rules Update workflow\r\n<img width=\"2560\"
alt=\"image\"
src=\"https://github.com/user-attachments/assets/59d157b2-6aca-4b21-95d0-f71a2e174df2\">\r\n\r\n-
event_category_override + tiebreaker_field + timestamp_field (aka EQL
options) in Prebuilt Rules Update workflow\r\n<img width=\"2552\"
alt=\"image\"
src=\"https://github.com/user-attachments/assets/1886d3b4-98f9-40a7-954c-2a6d4b8e925a\">\r\n\r\n-
Examples of invalid EQL\r\n<img width=\"2560\" alt=\"image\"
src=\"https://github.com/user-attachments/assets/d584deca-7903-45c5-9499-718552df441c\">\r\n\r\n<img
width=\"2548\" alt=\"image\"
src=\"https://github.com/user-attachments/assets/b734e22c-ab62-4624-85d0-e4e6dbb9d523\">","sha":"c0c803c8830c10f1df1b204a7d7b859f1f584991","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","Team:Detections
and Resp","Team: SecuritySolution","Team:Detection Rule
Management","Feature:Prebuilt Detection
Rules","backport:version","v8.18.0"],"number":199115,"url":"https://github.com/elastic/kibana/pull/199115","mergeCommit":{"message":"[Security
Solution] Add EQL query editable component with EQL options fields
(#199115)\n\n**Partially addresses:**
https://github.com/elastic/kibana/issues/171520\r\n\r\n##
Summary\r\n\r\nThis PR adds is built on top of
https://github.com/elastic/kibana/pull/193828 and
https://github.com/elastic/kibana/pull/196948 and adds an EQL Query
editable component with EQL Options fields (`event_category_override`,
`timestamp_field` and `tiebreaker_field`) for Three Way Diff tab's final
edit side of the upgrade prebuilt rule workflow.\r\n\r\n##
Details\r\n\r\nThis PR make a set of changes to make existing EQL Query
bar component easily reusable and type safe when used in forms. In
particular the following was done\r\n\r\n- EQL query bar was wrapped in
`EqlQueryEdit` component with `UseField` inside. It helps to make it
type safe avoiding issues like passing invalid types to `EqlQueryBar`.
`UseField` types component properties as `Record<string, any>` so
potentially any refactoring can break some functionality. For example
code in Timeline passes `DataViewSpec` where `DataViewBase` is expected
while these two types aren't fully compatible.\r\n- Validation was added
directly to `EqlQueryEdit`. Passing field configuration to `UseField`
rewrites field configuration defined in from schema. It leads to cases
when validation is defined in both form schema and as a field
configuration for `UseFields`. Additionally we can reduce reusing
complexity by incapsulating absolutely required validation in
`EqlQueryEdit` component.\r\n- Empty string `tiebreakerField` was
removed in Timelines. `tiebreakerField` is part of EQL options used for
EQL validation. EQL validation endpoint `/internal/search/eql` returns
an error when an empty string provided for `tiebreakerField`. This
problem didn't surface earlier since It looks like EQL options weren't
provided correctly before this PR. Timeline EQL validation requests were
sent without EQL options.\r\n\r\n## How to test\r\n\r\nThe simplest way
to test is via patching installed prebuilt rules via Rule Patch API.
Please follow steps below\r\n\r\n- Ensure the
`prebuiltRulesCustomizationEnabled` feature flag is enabled\r\n- Run
Kibana locally\r\n- Install an EQL prebuilt rule, e.g. `Potential Code
Execution via Postgresql` with rule_id
`2a692072-d78d-42f3-a48a-775677d79c4e`\r\n- Patch the installed rule by
running a query below\r\n\r\n```bash\r\ncurl -X PATCH --user
elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'
-H \"elastic-api-version: 2023-10-31\" -d
'{\"rule_id\":\"2a692072-d78d-42f3-a48a-775677d79c4e\",\"version\":1,\"query\":\"process
where process.name ==
\\\"cmd.exe\\\"\",\"language\":\"eql\",\"event_category_override\":
\"test\",\"timestamp_field\": \"@timestamp\",\"tiebreaker_field\":
\"tiebreaker\"}'
http://localhost:5601/kbn/api/detection_engine/rules\r\n```\r\n\r\n-
Open `Detection Rules (SIEM)` Page -> `Rule Updates` -> click on
`Potential Code Execution via Postgresql` rule -> expand `EQL Query` to
see EQL Query -> press `Edit` button\r\n\r\n## Screenshots\r\n\r\n- EQL
Query in Prebuilt Rules Update workflow\r\n<img width=\"2560\"
alt=\"image\"
src=\"https://github.com/user-attachments/assets/59d157b2-6aca-4b21-95d0-f71a2e174df2\">\r\n\r\n-
event_category_override + tiebreaker_field + timestamp_field (aka EQL
options) in Prebuilt Rules Update workflow\r\n<img width=\"2552\"
alt=\"image\"
src=\"https://github.com/user-attachments/assets/1886d3b4-98f9-40a7-954c-2a6d4b8e925a\">\r\n\r\n-
Examples of invalid EQL\r\n<img width=\"2560\" alt=\"image\"
src=\"https://github.com/user-attachments/assets/d584deca-7903-45c5-9499-718552df441c\">\r\n\r\n<img
width=\"2548\" alt=\"image\"
src=\"https://github.com/user-attachments/assets/b734e22c-ab62-4624-85d0-e4e6dbb9d523\">","sha":"c0c803c8830c10f1df1b204a7d7b859f1f584991"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/199115","number":199115,"mergeCommit":{"message":"[Security
Solution] Add EQL query editable component with EQL options fields
(#199115)\n\n**Partially addresses:**
https://github.com/elastic/kibana/issues/171520\r\n\r\n##
Summary\r\n\r\nThis PR adds is built on top of
https://github.com/elastic/kibana/pull/193828 and
https://github.com/elastic/kibana/pull/196948 and adds an EQL Query
editable component with EQL Options fields (`event_category_override`,
`timestamp_field` and `tiebreaker_field`) for Three Way Diff tab's final
edit side of the upgrade prebuilt rule workflow.\r\n\r\n##
Details\r\n\r\nThis PR make a set of changes to make existing EQL Query
bar component easily reusable and type safe when used in forms. In
particular the following was done\r\n\r\n- EQL query bar was wrapped in
`EqlQueryEdit` component with `UseField` inside. It helps to make it
type safe avoiding issues like passing invalid types to `EqlQueryBar`.
`UseField` types component properties as `Record<string, any>` so
potentially any refactoring can break some functionality. For example
code in Timeline passes `DataViewSpec` where `DataViewBase` is expected
while these two types aren't fully compatible.\r\n- Validation was added
directly to `EqlQueryEdit`. Passing field configuration to `UseField`
rewrites field configuration defined in from schema. It leads to cases
when validation is defined in both form schema and as a field
configuration for `UseFields`. Additionally we can reduce reusing
complexity by incapsulating absolutely required validation in
`EqlQueryEdit` component.\r\n- Empty string `tiebreakerField` was
removed in Timelines. `tiebreakerField` is part of EQL options used for
EQL validation. EQL validation endpoint `/internal/search/eql` returns
an error when an empty string provided for `tiebreakerField`. This
problem didn't surface earlier since It looks like EQL options weren't
provided correctly before this PR. Timeline EQL validation requests were
sent without EQL options.\r\n\r\n## How to test\r\n\r\nThe simplest way
to test is via patching installed prebuilt rules via Rule Patch API.
Please follow steps below\r\n\r\n- Ensure the
`prebuiltRulesCustomizationEnabled` feature flag is enabled\r\n- Run
Kibana locally\r\n- Install an EQL prebuilt rule, e.g. `Potential Code
Execution via Postgresql` with rule_id
`2a692072-d78d-42f3-a48a-775677d79c4e`\r\n- Patch the installed rule by
running a query below\r\n\r\n```bash\r\ncurl -X PATCH --user
elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'
-H \"elastic-api-version: 2023-10-31\" -d
'{\"rule_id\":\"2a692072-d78d-42f3-a48a-775677d79c4e\",\"version\":1,\"query\":\"process
where process.name ==
\\\"cmd.exe\\\"\",\"language\":\"eql\",\"event_category_override\":
\"test\",\"timestamp_field\": \"@timestamp\",\"tiebreaker_field\":
\"tiebreaker\"}'
http://localhost:5601/kbn/api/detection_engine/rules\r\n```\r\n\r\n-
Open `Detection Rules (SIEM)` Page -> `Rule Updates` -> click on
`Potential Code Execution via Postgresql` rule -> expand `EQL Query` to
see EQL Query -> press `Edit` button\r\n\r\n## Screenshots\r\n\r\n- EQL
Query in Prebuilt Rules Update workflow\r\n<img width=\"2560\"
alt=\"image\"
src=\"https://github.com/user-attachments/assets/59d157b2-6aca-4b21-95d0-f71a2e174df2\">\r\n\r\n-
event_category_override + tiebreaker_field + timestamp_field (aka EQL
options) in Prebuilt Rules Update workflow\r\n<img width=\"2552\"
alt=\"image\"
src=\"https://github.com/user-attachments/assets/1886d3b4-98f9-40a7-954c-2a6d4b8e925a\">\r\n\r\n-
Examples of invalid EQL\r\n<img width=\"2560\" alt=\"image\"
src=\"https://github.com/user-attachments/assets/d584deca-7903-45c5-9499-718552df441c\">\r\n\r\n<img
width=\"2548\" alt=\"image\"
src=\"https://github.com/user-attachments/assets/b734e22c-ab62-4624-85d0-e4e6dbb9d523\">","sha":"c0c803c8830c10f1df1b204a7d7b859f1f584991"}},{"branch":"8.x","label":"v8.18.0","labelRegex":"^v8.18.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Maxim Palenov 2024-11-22 10:37:22 +01:00 committed by GitHub
parent 54a410cb45
commit c12646f189
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1131 additions and 1053 deletions

View file

@ -12,3 +12,4 @@ export * from './src/axios';
export * from './src/transform_data_to_ndjson';
export * from './src/path_validations';
export * from './src/esql';
export * from './src/debounce_async/debounce_async';

View file

@ -1,22 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { debounceAsync } from './validators';
import { debounceAsync } from './debounce_async';
jest.useFakeTimers({ legacyFakeTimers: true });
describe('debounceAsync', () => {
let fn: jest.Mock;
beforeEach(() => {
fn = jest.fn().mockResolvedValueOnce('first');
});
it('resolves with the underlying invocation result', async () => {
const fn = jest.fn().mockResolvedValueOnce('first');
const debounced = debounceAsync(fn, 0);
const promise = debounced();
jest.runOnlyPendingTimers();
@ -25,6 +23,8 @@ describe('debounceAsync', () => {
});
it('resolves intermediate calls when the next invocation resolves', async () => {
const fn = jest.fn().mockResolvedValueOnce('first');
const debounced = debounceAsync(fn, 200);
fn.mockResolvedValueOnce('second');
@ -39,6 +39,8 @@ describe('debounceAsync', () => {
});
it('debounces the function', async () => {
const fn = jest.fn().mockResolvedValueOnce('first');
const debounced = debounceAsync(fn, 200);
debounced();

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
/**
* Unlike lodash's debounce, which resolves intermediate calls with the most
* recent value, this implementation waits to resolve intermediate calls until
* the next invocation resolves.
*
* @param fn an async function
*
* @returns A debounced async function that resolves on the next invocation
*/
export function debounceAsync<Args extends unknown[], Result>(
fn: (...args: Args) => Result,
intervalMs: number
): (...args: Args) => Promise<Awaited<Result>> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let resolve: (value: Awaited<Result>) => void;
let promise = new Promise<Awaited<Result>>((_resolve) => {
resolve = _resolve;
});
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(async () => {
resolve(await fn(...args));
promise = new Promise((_resolve) => {
resolve = _resolve;
});
}, intervalMs);
return promise;
};
}

View file

@ -9,14 +9,17 @@ import { z } from '@kbn/zod';
import {
BuildingBlockType,
DataViewId,
EventCategoryOverride,
IndexPatternArray,
KqlQueryLanguage,
RuleFilterArray,
RuleNameOverride,
RuleQuery,
SavedQueryId,
TiebreakerField,
TimelineTemplateId,
TimelineTemplateTitle,
TimestampField,
TimestampOverride,
TimestampOverrideFallbackDisabled,
} from '../../../../model/rule_schema';
@ -78,6 +81,9 @@ export const RuleEqlQuery = z.object({
query: RuleQuery,
language: z.literal('eql'),
filters: RuleFilterArray,
event_category_override: EventCategoryOverride.optional(),
timestamp_field: TimestampField.optional(),
tiebreaker_field: TiebreakerField.optional(),
});
export type RuleEsqlQuery = z.infer<typeof RuleEsqlQuery>;

View file

@ -9,7 +9,6 @@ import { z } from '@kbn/zod';
import {
AlertSuppression,
AnomalyThreshold,
EventCategoryOverride,
HistoryWindowStart,
InvestigationFields,
InvestigationGuide,
@ -37,8 +36,6 @@ import {
ThreatMapping,
Threshold,
ThresholdAlertSuppression,
TiebreakerField,
TimestampField,
} from '../../../../model/rule_schema';
import {
@ -113,9 +110,6 @@ export const DiffableEqlFields = z.object({
type: z.literal('eql'),
eql_query: RuleEqlQuery, // NOTE: new field
data_source: RuleDataSource.optional(), // NOTE: new field
event_category_override: EventCategoryOverride.optional(),
timestamp_field: TimestampField.optional(),
tiebreaker_field: TiebreakerField.optional(),
alert_suppression: AlertSuppression.optional(),
});

View file

@ -175,11 +175,15 @@ const extractDiffableEqlFieldsFromRuleObject = (
): RequiredOptional<DiffableEqlFields> => {
return {
type: rule.type,
eql_query: extractRuleEqlQuery(rule.query, rule.language, rule.filters),
eql_query: extractRuleEqlQuery({
query: rule.query,
language: rule.language,
filters: rule.filters,
eventCategoryOverride: rule.event_category_override,
timestampField: rule.timestamp_field,
tiebreakerField: rule.tiebreaker_field,
}),
data_source: extractRuleDataSource(rule.index, rule.data_view_id),
event_category_override: rule.event_category_override,
timestamp_field: rule.timestamp_field,
tiebreaker_field: rule.tiebreaker_field,
alert_suppression: rule.alert_suppression,
};
};

View file

@ -8,9 +8,12 @@
import type {
EqlQueryLanguage,
EsqlQueryLanguage,
EventCategoryOverride,
KqlQueryLanguage,
RuleFilterArray,
RuleQuery,
TiebreakerField,
TimestampField,
} from '../../../api/detection_engine/model/rule_schema';
import type {
InlineKqlQuery,
@ -49,15 +52,23 @@ export const extractInlineKqlQuery = (
};
};
export const extractRuleEqlQuery = (
query: RuleQuery,
language: EqlQueryLanguage,
filters: RuleFilterArray | undefined
): RuleEqlQuery => {
interface ExtractRuleEqlQueryParams {
query: RuleQuery;
language: EqlQueryLanguage;
filters: RuleFilterArray | undefined;
eventCategoryOverride: EventCategoryOverride | undefined;
timestampField: TimestampField | undefined;
tiebreakerField: TiebreakerField | undefined;
}
export const extractRuleEqlQuery = (params: ExtractRuleEqlQueryParams): RuleEqlQuery => {
return {
query,
language,
filters: filters ?? [],
query: params.query,
language: params.language,
filters: params.filters ?? [],
event_category_override: params.eventCategoryOverride,
timestamp_field: params.timestampField,
tiebreaker_field: params.tiebreakerField,
};
};

View file

@ -7,7 +7,7 @@
export type {
TimelineEqlResponse,
EqlOptionsData,
EqlOptionsSelected,
EqlFieldsComboBoxOptions,
EqlOptions,
FieldsEqlOptions,
} from '@kbn/timelines-plugin/common';

View file

@ -34,7 +34,7 @@ const triggerValidateEql = () => {
query: 'any where true',
signal,
runtimeMappings: undefined,
options: undefined,
eqlOptions: undefined,
});
};

View file

@ -11,7 +11,7 @@ import type { EqlSearchStrategyRequest, EqlSearchStrategyResponse } from '@kbn/d
import { EQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { EqlOptionsSelected } from '../../../../common/search_strategy';
import type { EqlOptions } from '../../../../common/search_strategy';
import {
getValidationErrors,
isErrorResponse,
@ -31,9 +31,9 @@ interface Params {
dataViewTitle: string;
query: string;
data: DataPublicPluginStart;
signal: AbortSignal;
runtimeMappings: estypes.MappingRuntimeFields | undefined;
options: Omit<EqlOptionsSelected, 'query' | 'size'> | undefined;
eqlOptions: Omit<EqlOptions, 'query' | 'size'> | undefined;
signal?: AbortSignal;
}
export interface EqlResponseError {
@ -51,9 +51,9 @@ export const validateEql = async ({
data,
dataViewTitle,
query,
signal,
runtimeMappings,
options,
eqlOptions,
signal,
}: Params): Promise<ValidateEqlResponse> => {
try {
const { rawResponse: response } = await firstValueFrom(
@ -62,9 +62,12 @@ export const validateEql = async ({
params: {
index: dataViewTitle,
body: { query, runtime_mappings: runtimeMappings, size: 0 },
timestamp_field: options?.timestampField,
tiebreaker_field: options?.tiebreakerField || undefined,
event_category_field: options?.eventCategoryField,
// Prevent passing empty string values
timestamp_field: eqlOptions?.timestampField ? eqlOptions.timestampField : undefined,
tiebreaker_field: eqlOptions?.tiebreakerField ? eqlOptions.tiebreakerField : undefined,
event_category_field: eqlOptions?.eventCategoryField
? eqlOptions.eventCategoryField
: undefined,
},
options: { ignore: [400] },
},
@ -79,19 +82,23 @@ export const validateEql = async ({
valid: false,
error: { code: EQL_ERROR_CODES.INVALID_SYNTAX, messages: getValidationErrors(response) },
};
} else if (isVerificationErrorResponse(response) || isMappingErrorResponse(response)) {
}
if (isVerificationErrorResponse(response) || isMappingErrorResponse(response)) {
return {
valid: false,
error: { code: EQL_ERROR_CODES.INVALID_EQL, messages: getValidationErrors(response) },
};
} else if (isErrorResponse(response)) {
}
if (isErrorResponse(response)) {
return {
valid: false,
error: { code: EQL_ERROR_CODES.FAILED_REQUEST, error: new Error(JSON.stringify(response)) },
};
} else {
return { valid: true };
}
return { valid: true };
} catch (error) {
if (error instanceof Error && error.message.startsWith('index_not_found_exception')) {
return {
@ -99,6 +106,7 @@ export const validateEql = async ({
error: { code: EQL_ERROR_CODES.MISSING_DATA_SOURCE, messages: [error.message] },
};
}
return {
valid: false,
error: {

View file

@ -349,7 +349,6 @@ export const mockGlobalState: State = {
description: '',
eqlOptions: {
eventCategoryField: 'event.category',
tiebreakerField: '',
timestampField: '@timestamp',
},
eventIdToNoteIds: { '1': ['1'] },

View file

@ -2051,7 +2051,6 @@ export const defaultTimelineProps: CreateTimelineProps = {
eventCategoryField: 'event.category',
query: '',
size: 100,
tiebreakerField: '',
timestampField: '@timestamp',
},
eventIdToNoteIds: {},

View file

@ -8,9 +8,10 @@
import React from 'react';
import { shallow } from 'enzyme';
import { render, screen, fireEvent, within } from '@testing-library/react';
import type { SecuritySolutionDataViewBase } from '../../../../common/types';
import { mockIndexPattern, TestProviders, useFormFieldMock } from '../../../../common/mock';
import { mockQueryBar } from '../../../rule_management_ui/components/rules_table/__mocks__/mock';
import { selectEuiComboBoxOption } from '../../../../common/test/eui/combobox';
import type { EqlQueryBarProps } from './eql_query_bar';
import { EqlQueryBar } from './eql_query_bar';
import { getEqlValidationError } from './validators.mock';
@ -115,36 +116,82 @@ describe('EqlQueryBar', () => {
});
describe('EQL options interaction', () => {
const mockOptionsData = {
keywordFields: [],
dateFields: [{ label: 'timestamp', value: 'timestamp' }],
nonDateFields: [],
const mockIndexPatternWithEqlOptionsFields: SecuritySolutionDataViewBase = {
fields: [
{
name: 'category',
searchable: true,
type: 'keyword',
esTypes: ['keyword'],
aggregatable: true,
},
{
name: 'timestamp',
searchable: true,
type: 'date',
aggregatable: true,
},
{
name: 'tiebreaker',
searchable: true,
type: 'string',
aggregatable: true,
},
],
title: 'test-*',
};
it('invokes onOptionsChange when the EQL options change', () => {
const onOptionsChangeMock = jest.fn();
it('updates EQL options', async () => {
let eqlOptions = {};
const { getByTestId, getByText } = render(
const mockEqlOptionsField = useFormFieldMock({
value: {},
setValue: (updater) => {
if (typeof updater === 'function') {
eqlOptions = updater(eqlOptions);
}
},
});
const { getByTestId } = render(
<TestProviders>
<EqlQueryBar
dataTestSubj="myQueryBar"
field={mockField}
eqlOptionsField={mockEqlOptionsField}
isLoading={false}
optionsData={mockOptionsData}
indexPattern={mockIndexPattern}
onOptionsChange={onOptionsChangeMock}
indexPattern={mockIndexPatternWithEqlOptionsFields}
/>
</TestProviders>
);
// open options popover
fireEvent.click(getByTestId('eql-settings-trigger'));
// display combobox options
within(getByTestId(`eql-timestamp-field`)).getByRole('combobox').focus();
// select timestamp
getByText('timestamp').click();
expect(onOptionsChangeMock).toHaveBeenCalledWith('timestampField', 'timestamp');
await selectEuiComboBoxOption({
comboBoxToggleButton: within(getByTestId('eql-event-category-field')).getByRole('combobox'),
optionText: 'category',
});
expect(eqlOptions).toEqual({ eventCategoryField: 'category' });
await selectEuiComboBoxOption({
comboBoxToggleButton: within(getByTestId('eql-tiebreaker-field')).getByRole('combobox'),
optionText: 'tiebreaker',
});
expect(eqlOptions).toEqual({ eventCategoryField: 'category', tiebreakerField: 'tiebreaker' });
await selectEuiComboBoxOption({
comboBoxToggleButton: within(getByTestId('eql-timestamp-field')).getByRole('combobox'),
optionText: 'timestamp',
});
expect(eqlOptions).toEqual({
eventCategoryField: 'category',
tiebreakerField: 'tiebreaker',
timestampField: 'timestamp',
});
});
});
});

View file

@ -17,16 +17,13 @@ import { FilterManager } from '@kbn/data-plugin/public';
import type { FieldHook } from '../../../../shared_imports';
import { FilterBar } from '../../../../common/components/filter_bar';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types';
import * as i18n from './translations';
import type { EqlOptions } from '../../../../../common/search_strategy';
import { useKibana } from '../../../../common/lib/kibana';
import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar';
import type { EqlQueryBarFooterProps } from './footer';
import { EqlQueryBarFooter } from './footer';
import { getValidationResults } from './validators';
import type {
EqlOptionsData,
EqlOptionsSelected,
FieldsEqlOptions,
} from '../../../../../common/search_strategy';
import { useKibana } from '../../../../common/lib/kibana';
import * as i18n from './translations';
const TextArea = styled(EuiTextArea)`
display: block;
@ -60,32 +57,28 @@ const StyledFormRow = styled(EuiFormRow)`
export interface EqlQueryBarProps {
dataTestSubj: string;
field: FieldHook<DefineStepRule['queryBar']>;
isLoading: boolean;
field: FieldHook<FieldValueQueryBar>;
eqlOptionsField?: FieldHook<EqlOptions>;
isLoading?: boolean;
indexPattern: DataViewBase;
showFilterBar?: boolean;
idAria?: string;
optionsData?: EqlOptionsData;
optionsSelected?: EqlOptionsSelected;
isSizeOptionDisabled?: boolean;
onOptionsChange?: (field: FieldsEqlOptions, newValue: string | undefined) => void;
onValidityChange?: (arg: boolean) => void;
onValiditingChange?: (arg: boolean) => void;
onValidatingChange?: (arg: boolean) => void;
}
export const EqlQueryBar: FC<EqlQueryBarProps> = ({
dataTestSubj,
field,
eqlOptionsField,
isLoading = false,
indexPattern,
showFilterBar,
idAria,
optionsData,
optionsSelected,
isSizeOptionDisabled,
onOptionsChange,
onValidityChange,
onValiditingChange,
onValidatingChange,
}) => {
const { addError } = useAppToasts();
const [errorMessages, setErrorMessages] = useState<string[]>([]);
@ -115,10 +108,10 @@ export const EqlQueryBar: FC<EqlQueryBarProps> = ({
}, [error, addError]);
useEffect(() => {
if (onValiditingChange) {
onValiditingChange(isValidating);
if (onValidatingChange) {
onValidatingChange(isValidating);
}
}, [isValidating, onValiditingChange]);
}, [isValidating, onValidatingChange]);
useEffect(() => {
let isSubscribed = true;
@ -156,8 +149,8 @@ export const EqlQueryBar: FC<EqlQueryBarProps> = ({
const handleChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
const newQuery = e.target.value;
if (onValiditingChange) {
onValiditingChange(true);
if (onValidatingChange) {
onValidatingChange(true);
}
setErrorMessages([]);
setFieldValue({
@ -169,7 +162,19 @@ export const EqlQueryBar: FC<EqlQueryBarProps> = ({
saved_id: null,
});
},
[fieldValue, setFieldValue, onValiditingChange]
[fieldValue, setFieldValue, onValidatingChange]
);
const handleEqlOptionsChange = useCallback<
NonNullable<EqlQueryBarFooterProps['onEqlOptionsChange']>
>(
(eqlOptionsFieldName, value) => {
eqlOptionsField?.setValue((prevEqlOptions) => ({
...prevEqlOptions,
[eqlOptionsFieldName]: value,
}));
},
[eqlOptionsField]
);
return (
@ -195,9 +200,9 @@ export const EqlQueryBar: FC<EqlQueryBarProps> = ({
errors={errorMessages}
isLoading={isValidating}
isSizeOptionDisabled={isSizeOptionDisabled}
optionsData={optionsData}
optionsSelected={optionsSelected}
onOptionsChange={onOptionsChange}
dataView={indexPattern}
eqlOptions={eqlOptionsField?.value}
onEqlOptionsChange={handleEqlOptionsChange}
/>
{showFilterBar && (
<>

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import type { DataViewBase } from '@kbn/es-query';
import { debounceAsync } from '@kbn/securitysolution-utils';
import type { FormData, FieldConfig, ValidationFuncArg } from '../../../../shared_imports';
import { UseMultiFields } from '../../../../shared_imports';
import type { EqlFieldsComboBoxOptions, EqlOptions } from '../../../../../common/search_strategy';
import { queryRequiredValidatorFactory } from '../../../rule_creation_ui/validators/query_required_validator_factory';
import { eqlQueryValidatorFactory } from './eql_query_validator_factory';
import { EqlQueryBar } from './eql_query_bar';
import * as i18n from './translations';
import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar';
interface EqlQueryEditProps {
path: string;
eqlOptionsPath: string;
fieldsToValidateOnChange?: string | string[];
eqlFieldsComboBoxOptions?: EqlFieldsComboBoxOptions;
showEqlSizeOption?: boolean;
showFilterBar?: boolean;
dataView: DataViewBase;
required?: boolean;
loading?: boolean;
disabled?: boolean;
// This is a temporal solution for Prebuilt Customization workflow
skipEqlValidation?: boolean;
onValidityChange?: (arg: boolean) => void;
}
export function EqlQueryEdit({
path,
eqlOptionsPath,
fieldsToValidateOnChange,
showEqlSizeOption = false,
showFilterBar = false,
dataView,
required,
loading,
disabled,
skipEqlValidation,
onValidityChange,
}: EqlQueryEditProps): JSX.Element {
const componentProps = useMemo(
() => ({
isSizeOptionDisabled: !showEqlSizeOption,
isDisabled: disabled,
isLoading: loading,
indexPattern: dataView,
showFilterBar,
idAria: 'ruleEqlQueryBar',
dataTestSubj: 'ruleEqlQueryBar',
onValidityChange,
}),
[showEqlSizeOption, showFilterBar, onValidityChange, dataView, loading, disabled]
);
const fieldConfig: FieldConfig<FieldValueQueryBar> = useMemo(
() => ({
label: i18n.EQL_QUERY_BAR_LABEL,
fieldsToValidateOnChange: fieldsToValidateOnChange
? [path, fieldsToValidateOnChange].flat()
: undefined,
validations: [
...(required
? [
{
validator: queryRequiredValidatorFactory('eql'),
},
]
: []),
...(!skipEqlValidation
? [
{
validator: debounceAsync(
(data: ValidationFuncArg<FormData, FieldValueQueryBar>) => {
const { formData } = data;
const eqlOptions =
eqlOptionsPath && formData[eqlOptionsPath] ? formData[eqlOptionsPath] : {};
return eqlQueryValidatorFactory(
dataView.id
? {
dataViewId: dataView.id,
eqlOptions,
}
: {
indexPatterns: dataView.title.split(','),
eqlOptions,
}
)(data);
},
300
),
},
]
: []),
],
}),
[
skipEqlValidation,
eqlOptionsPath,
required,
dataView.id,
dataView.title,
path,
fieldsToValidateOnChange,
]
);
return (
<UseMultiFields<{
eqlQuery: FieldValueQueryBar;
eqlOptions: EqlOptions;
}>
fields={{
eqlQuery: {
path,
config: fieldConfig,
},
eqlOptions: {
path: eqlOptionsPath,
},
}}
>
{({ eqlQuery, eqlOptions }) => (
<EqlQueryBar field={eqlQuery} eqlOptionsField={eqlOptions} {...componentProps} />
)}
</UseMultiFields>
);
}

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash';
import type { FormData, ValidationError, ValidationFunc } from '../../../../shared_imports';
import { KibanaServices } from '../../../../common/lib/kibana';
import type { EqlOptions } from '../../../../../common/search_strategy';
import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar';
import type { EqlResponseError } from '../../../../common/hooks/eql/api';
import { EQL_ERROR_CODES, validateEql } from '../../../../common/hooks/eql/api';
import { EQL_VALIDATION_REQUEST_ERROR } from './translations';
type EqlQueryValidatorFactoryParams =
| {
indexPatterns: string[];
dataViewId?: never;
eqlOptions: EqlOptions;
}
| {
indexPatterns?: never;
dataViewId: string;
eqlOptions: EqlOptions;
};
export function eqlQueryValidatorFactory({
indexPatterns,
dataViewId,
eqlOptions,
}: EqlQueryValidatorFactoryParams): ValidationFunc<FormData, string, FieldValueQueryBar> {
return async (...args) => {
const [{ value }] = args;
if (isEmpty(value.query.query)) {
return;
}
try {
const { data } = KibanaServices.get();
const dataView = isDataViewIdValid(dataViewId)
? await data.dataViews.get(dataViewId)
: undefined;
const dataViewTitle = dataView?.getIndexPattern() ?? indexPatterns?.join(',') ?? '';
const runtimeMappings = dataView?.getRuntimeMappings() ?? {};
const response = await validateEql({
data,
query: value.query.query as string,
dataViewTitle,
runtimeMappings,
eqlOptions,
});
if (response?.valid === false && response.error) {
return transformEqlResponseErrorToValidationError(response.error);
}
} catch (error) {
return {
code: EQL_ERROR_CODES.FAILED_REQUEST,
message: EQL_VALIDATION_REQUEST_ERROR,
error,
};
}
};
}
function transformEqlResponseErrorToValidationError(
responseError: EqlResponseError
): ValidationError<EQL_ERROR_CODES> {
if (responseError.error) {
return {
code: EQL_ERROR_CODES.FAILED_REQUEST,
message: EQL_VALIDATION_REQUEST_ERROR,
error: responseError.error,
};
}
return {
code: responseError.code,
message: '',
messages: responseError.messages,
};
}
function isDataViewIdValid(dataViewId: unknown): dataViewId is string {
return typeof dataViewId === 'string' && dataViewId !== '';
}

View file

@ -32,7 +32,11 @@ describe('EQL footer', () => {
it('EQL settings button is enable when popover is NOT open', () => {
const wrapper = mount(
<TestProviders>
<EqlQueryBarFooter errors={[]} onOptionsChange={jest.fn()} />
<EqlQueryBarFooter
errors={[]}
dataView={{ title: '', fields: [] }}
onEqlOptionsChange={jest.fn()}
/>
</TestProviders>
);
@ -44,7 +48,11 @@ describe('EQL footer', () => {
it('disable EQL settings button when popover is open', () => {
const wrapper = mount(
<TestProviders>
<EqlQueryBarFooter errors={[]} onOptionsChange={jest.fn()} />
<EqlQueryBarFooter
errors={[]}
dataView={{ title: '', fields: [] }}
onEqlOptionsChange={jest.fn()}
/>
</TestProviders>
);
wrapper.find(`[data-test-subj="eql-settings-trigger"]`).first().simulate('click');

View file

@ -20,28 +20,27 @@ import {
import type { FC } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import type { DataViewBase } from '@kbn/es-query';
import type { DebouncedFunc } from 'lodash';
import { debounce } from 'lodash';
import type {
EqlOptionsData,
EqlOptionsSelected,
FieldsEqlOptions,
} from '../../../../../common/search_strategy';
import { debounce, isEmpty } from 'lodash';
import type { EqlOptions } from '../../../../../common/search_strategy';
import * as i18n from './translations';
import { ErrorsPopover } from './errors_popover';
import { EqlOverviewLink } from './eql_overview_link';
export interface Props {
export interface EqlQueryBarFooterProps {
errors: string[];
isLoading?: boolean;
isSizeOptionDisabled?: boolean;
optionsData?: EqlOptionsData;
optionsSelected?: EqlOptionsSelected;
onOptionsChange?: (field: FieldsEqlOptions, newValue: string | undefined) => void;
dataView: DataViewBase;
eqlOptions?: EqlOptions;
onEqlOptionsChange?: <Field extends keyof EqlOptions>(
field: Field,
newValue: EqlOptions[Field]
) => void;
}
type SizeVoidFunc = (newSize: string) => void;
type SizeVoidFunc = (newSize: number) => void;
const Container = styled(EuiFlexGroup)`
border-radius: 0;
@ -69,18 +68,40 @@ const Spinner = styled(EuiLoadingSpinner)`
const singleSelection = { asPlainText: true };
export const EqlQueryBarFooter: FC<Props> = ({
export const EqlQueryBarFooter: FC<EqlQueryBarFooterProps> = ({
errors,
isLoading,
isSizeOptionDisabled,
optionsData,
optionsSelected,
onOptionsChange,
dataView,
eqlOptions,
onEqlOptionsChange,
}) => {
const [openEqlSettings, setIsOpenEqlSettings] = useState(false);
const [localSize, setLocalSize] = useState<string | number>(optionsSelected?.size ?? 100);
const [localSize, setLocalSize] = useState<number>(eqlOptions?.size ?? 100);
const debounceSize = useRef<DebouncedFunc<SizeVoidFunc>>();
const { keywordFields, nonDateFields, dateFields } = useMemo(
() =>
isEmpty(dataView?.fields)
? {
keywordFields: [],
dateFields: [],
nonDateFields: [],
}
: {
keywordFields: dataView.fields
.filter((f) => f.esTypes?.includes('keyword'))
.map((f) => ({ label: f.name })),
dateFields: dataView.fields
.filter((f) => f.type === 'date')
.map((f) => ({ label: f.name })),
nonDateFields: dataView.fields
.filter((f) => f.type !== 'date')
.map((f) => ({ label: f.name })),
},
[dataView]
);
const openEqlSettingsHandler = useCallback(() => {
setIsOpenEqlSettings(true);
}, []);
@ -90,74 +111,70 @@ export const EqlQueryBarFooter: FC<Props> = ({
const handleEventCategoryField = useCallback(
(opt: EuiComboBoxOptionOption[]) => {
if (onOptionsChange) {
if (onEqlOptionsChange) {
if (opt.length > 0) {
onOptionsChange('eventCategoryField', opt[0].label);
onEqlOptionsChange('eventCategoryField', opt[0].label);
} else {
onOptionsChange('eventCategoryField', undefined);
onEqlOptionsChange('eventCategoryField', undefined);
}
}
},
[onOptionsChange]
[onEqlOptionsChange]
);
const handleTiebreakerField = useCallback(
(opt: EuiComboBoxOptionOption[]) => {
if (onOptionsChange) {
if (onEqlOptionsChange) {
if (opt.length > 0) {
onOptionsChange('tiebreakerField', opt[0].label);
onEqlOptionsChange('tiebreakerField', opt[0].label);
} else {
onOptionsChange('tiebreakerField', undefined);
onEqlOptionsChange('tiebreakerField', undefined);
}
}
},
[onOptionsChange]
[onEqlOptionsChange]
);
const handleTimestampField = useCallback(
(opt: EuiComboBoxOptionOption[]) => {
if (onOptionsChange) {
if (onEqlOptionsChange) {
if (opt.length > 0) {
onOptionsChange('timestampField', opt[0].label);
onEqlOptionsChange('timestampField', opt[0].label);
} else {
onOptionsChange('timestampField', undefined);
onEqlOptionsChange('timestampField', undefined);
}
}
},
[onOptionsChange]
[onEqlOptionsChange]
);
const handleSizeField = useCallback<NonNullable<EuiFieldNumberProps['onChange']>>(
(evt) => {
if (onOptionsChange) {
if (onEqlOptionsChange) {
setLocalSize(evt?.target?.valueAsNumber);
if (debounceSize.current?.cancel) {
debounceSize.current?.cancel();
}
debounceSize.current = debounce((newSize) => onOptionsChange('size', newSize), 800);
debounceSize.current(evt?.target?.value);
debounceSize.current = debounce((newSize) => onEqlOptionsChange('size', newSize), 800);
debounceSize.current(evt?.target?.valueAsNumber);
}
},
[onOptionsChange]
[onEqlOptionsChange]
);
const eventCategoryField = useMemo(
() =>
optionsSelected?.eventCategoryField != null
? [{ label: optionsSelected?.eventCategoryField }]
eqlOptions?.eventCategoryField != null
? [{ label: eqlOptions?.eventCategoryField }]
: undefined,
[optionsSelected?.eventCategoryField]
[eqlOptions?.eventCategoryField]
);
const tiebreakerField = useMemo(
() =>
optionsSelected?.tiebreakerField != null
? [{ label: optionsSelected?.tiebreakerField }]
: undefined,
[optionsSelected?.tiebreakerField]
eqlOptions?.tiebreakerField != null ? [{ label: eqlOptions?.tiebreakerField }] : undefined,
[eqlOptions?.tiebreakerField]
);
const timestampField = useMemo(
() =>
optionsSelected?.timestampField != null
? [{ label: optionsSelected?.timestampField }]
: undefined,
[optionsSelected?.timestampField]
eqlOptions?.timestampField != null ? [{ label: eqlOptions?.timestampField }] : undefined,
[eqlOptions?.timestampField]
);
return (
@ -183,13 +200,13 @@ export const EqlQueryBarFooter: FC<Props> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize={'none'} alignItems="center" responsive={false}>
{!onOptionsChange && (
{!onEqlOptionsChange && (
<EuiFlexItem grow={false}>
<EqlOverviewLink />
</EuiFlexItem>
)}
{onOptionsChange && (
{onEqlOptionsChange && (
<>
<FlexItemWithMarginRight grow={false}>
<EqlOverviewLink />
@ -232,7 +249,7 @@ export const EqlQueryBarFooter: FC<Props> = ({
helpText={i18n.EQL_OPTIONS_EVENT_CATEGORY_FIELD_HELPER}
>
<EuiComboBox
options={optionsData?.keywordFields}
options={keywordFields}
selectedOptions={eventCategoryField}
singleSelection={singleSelection}
onChange={handleEventCategoryField}
@ -244,7 +261,7 @@ export const EqlQueryBarFooter: FC<Props> = ({
helpText={i18n.EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_HELPER}
>
<EuiComboBox
options={optionsData?.nonDateFields}
options={nonDateFields}
selectedOptions={tiebreakerField}
singleSelection={singleSelection}
onChange={handleTiebreakerField}
@ -256,7 +273,7 @@ export const EqlQueryBarFooter: FC<Props> = ({
helpText={i18n.EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_HELPER}
>
<EuiComboBox
options={optionsData?.dateFields}
options={dateFields}
selectedOptions={timestampField}
singleSelection={singleSelection}
onChange={handleTimestampField}

View file

@ -7,6 +7,13 @@
import { i18n } from '@kbn/i18n';
export const EQL_QUERY_BAR_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.EqlQueryBarLabel',
{
defaultMessage: 'EQL query',
}
);
export const EQL_VALIDATION_REQUEST_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.eqlValidation.requestError',
{

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FieldHook } from '../../../../shared_imports';
import { EQL_ERROR_CODES } from '../../../../common/hooks/eql/api';
export const getValidationResults = <T = unknown>(
field: FieldHook<T>
): { isValid: boolean; message: string; messages?: string[]; error?: Error } => {
const hasErrors = field.errors.length > 0;
const isValid = !field.isChangingValue && !hasErrors;
if (hasErrors) {
const [error] = field.errors;
const message = error.message;
if (error.code === EQL_ERROR_CODES.FAILED_REQUEST) {
return { isValid, message, error: error.error };
} else {
return { isValid, message, messages: error.messages };
}
} else {
return { isValid, message: '' };
}
};

View file

@ -32,7 +32,7 @@ import type {
} from '../../../../../common/api/detection_engine/model/rule_schema';
import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine/model/rule_schema';
import { MATCHES, AND, OR } from '../../../../common/components/threat_match/translations';
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
import type { EqlOptions } from '../../../../../common/search_strategy';
import { assertUnreachable } from '../../../../../common/utility_types';
import * as i18nSeverity from '../severity_mapping/translations';
import * as i18nRiskScore from '../risk_score_mapping/translations';
@ -147,7 +147,7 @@ export const buildQueryBarDescription = ({
return items;
};
export const buildEqlOptionsDescription = (eqlOptions: EqlOptionsSelected): ListItems[] => {
export const buildEqlOptionsDescription = (eqlOptions: EqlOptions): ListItems[] => {
let items: ListItems[] = [];
if (!isEmpty(eqlOptions.eventCategoryField)) {
items = [

View file

@ -19,7 +19,7 @@ import type {
} from '../../../../../common/api/detection_engine/model/rule_schema';
import { buildRelatedIntegrationsDescription } from '../../../../detections/components/rules/related_integrations/integrations_description';
import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations';
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
import type { EqlOptions } from '../../../../../common/search_strategy';
import { useKibana } from '../../../../common/lib/kibana';
import type {
AboutStepRiskScore,
@ -275,7 +275,7 @@ export const getDescriptionItem = (
return [];
}
} else if (field === 'eqlOptions') {
const eqlOptions: EqlOptionsSelected = get(field, data);
const eqlOptions: EqlOptions = get(field, data);
return buildEqlOptionsDescription(eqlOptions);
} else if (field === 'threat') {
const threats: Threats = get(field, data);

View file

@ -1,137 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash';
import type { FieldHook, ValidationError, ValidationFunc } from '../../../../shared_imports';
import { isEqlRule } from '../../../../../common/detection_engine/utils';
import { KibanaServices } from '../../../../common/lib/kibana';
import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types';
import { DataSourceType } from '../../../../detections/pages/detection_engine/rules/types';
import type { EqlResponseError } from '../../../../common/hooks/eql/api';
import { validateEql, EQL_ERROR_CODES } from '../../../../common/hooks/eql/api';
import type { FieldValueQueryBar } from '../query_bar';
import * as i18n from './translations';
/**
* Unlike lodash's debounce, which resolves intermediate calls with the most
* recent value, this implementation waits to resolve intermediate calls until
* the next invocation resolves.
*
* @param fn an async function
*
* @returns A debounced async function that resolves on the next invocation
*/
export const debounceAsync = <Args extends unknown[], Result extends Promise<unknown>>(
fn: (...args: Args) => Result,
interval: number
): ((...args: Args) => Result) => {
let handle: ReturnType<typeof setTimeout> | undefined;
let resolves: Array<(value?: Result) => void> = [];
return (...args: Args): Result => {
if (handle) {
clearTimeout(handle);
}
handle = setTimeout(() => {
const result = fn(...args);
resolves.forEach((resolve) => resolve(result));
resolves = [];
}, interval);
return new Promise((resolve) => resolves.push(resolve)) as Result;
};
};
export const transformEqlResponseErrorToValidationError = (
responseError: EqlResponseError
): ValidationError<EQL_ERROR_CODES> => {
if (responseError.error) {
return {
code: EQL_ERROR_CODES.FAILED_REQUEST,
message: i18n.EQL_VALIDATION_REQUEST_ERROR,
error: responseError.error,
};
}
return {
code: responseError.code,
message: '',
messages: responseError.messages,
};
};
export const eqlValidator = async (
...args: Parameters<ValidationFunc>
): Promise<ValidationError<EQL_ERROR_CODES> | void | undefined> => {
const [{ value, formData }] = args;
const { query: queryValue } = value as FieldValueQueryBar;
const query = queryValue.query as string;
const { dataViewId, index, ruleType, eqlOptions } = formData as DefineStepRule;
const needsValidation =
(ruleType === undefined && !isEmpty(query)) || (isEqlRule(ruleType) && !isEmpty(query));
if (!needsValidation) {
return;
}
try {
const { data } = KibanaServices.get();
let dataViewTitle = index?.join();
let runtimeMappings = {};
if (
dataViewId != null &&
dataViewId !== '' &&
formData.dataSourceType === DataSourceType.DataView
) {
const dataView = await data.dataViews.get(dataViewId);
dataViewTitle = dataView.title;
runtimeMappings = dataView.getRuntimeMappings();
}
const signal = new AbortController().signal;
const response = await validateEql({
data,
query,
signal,
dataViewTitle,
runtimeMappings,
options: eqlOptions,
});
if (response?.valid === false && response.error) {
return transformEqlResponseErrorToValidationError(response.error);
}
} catch (error) {
return {
code: EQL_ERROR_CODES.FAILED_REQUEST,
message: i18n.EQL_VALIDATION_REQUEST_ERROR,
error,
};
}
};
export const getValidationResults = <T = unknown>(
field: FieldHook<T>
): { isValid: boolean; message: string; messages?: string[]; error?: Error } => {
const hasErrors = field.errors.length > 0;
const isValid = !field.isChangingValue && !hasErrors;
if (hasErrors) {
const [error] = field.errors;
const message = error.message;
if (error.code === EQL_ERROR_CODES.FAILED_REQUEST) {
return { isValid, message, error: error.error };
} else {
return { isValid, message, messages: error.messages };
}
} else {
return { isValid, message: '' };
}
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { screen, fireEvent, render, within, act, waitFor } from '@testing-library/react';
import type { Type as RuleType } from '@kbn/securitysolution-io-ts-alerting-types';
import type { DataViewBase } from '@kbn/es-query';
@ -638,7 +638,6 @@ function TestForm({
onSubmit,
formProps,
}: TestFormProps): JSX.Element {
const [selectedEqlOptions, setSelectedEqlOptions] = useState(stepDefineDefaultValue.eqlOptions);
const { form } = useForm({
options: { stripEmptyFields: false },
schema: defineRuleSchema,
@ -653,8 +652,6 @@ function TestForm({
form={form}
indicesConfig={[]}
threatIndicesConfig={[]}
optionsSelected={selectedEqlOptions}
setOptionsSelected={setSelectedEqlOptions}
indexPattern={indexPattern}
isIndexPatternLoading={false}
isQueryBarValid={true}

View file

@ -20,7 +20,7 @@ import React, { memo, useCallback, useState, useEffect, useMemo, useRef } from '
import styled from 'styled-components';
import { i18n as i18nCore } from '@kbn/i18n';
import { isEqual, isEmpty } from 'lodash';
import { isEqual } from 'lodash';
import type { FieldSpec } from '@kbn/data-plugin/common';
import usePrevious from 'react-use/lib/usePrevious';
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
@ -33,7 +33,6 @@ import { useSetFieldValueWithCallback } from '../../../../common/utils/use_set_f
import type { SetRuleQuery } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline';
import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
import type { EqlOptionsSelected, FieldsEqlOptions } from '../../../../../common/search_strategy';
import { filterRuleFieldsForType, getStepDataDataSource } from '../../pages/rule_creation/helpers';
import type {
DefineStepRule,
@ -74,7 +73,7 @@ import {
isEqlSequenceQuery,
isSuppressionRuleInGA,
} from '../../../../../common/detection_engine/utils';
import { EqlQueryBar } from '../eql_query_bar';
import { EqlQueryEdit } from '../../../rule_creation/components/eql_query_edit';
import { DataViewSelectorField } from '../data_view_selector_field';
import { ThreatMatchInput } from '../threatmatch_input';
import { useFetchIndex } from '../../../../common/containers/source';
@ -91,7 +90,10 @@ import { useAlertSuppression } from '../../../rule_management/logic/use_alert_su
import { AiAssistant } from '../ai_assistant';
import { RelatedIntegrations } from '../../../rule_creation/components/related_integrations';
import { useMLRuleConfig } from '../../../../common/components/ml/hooks/use_ml_rule_config';
import { AlertSuppressionEdit } from '../../../rule_creation/components/alert_suppression_edit';
import {
ALERT_SUPPRESSION_FIELDS_FIELD_NAME,
AlertSuppressionEdit,
} from '../../../rule_creation/components/alert_suppression_edit';
import { ThresholdAlertSuppressionEdit } from '../../../rule_creation/components/threshold_alert_suppression_edit';
import { usePersistentAlertSuppressionState } from './use_persistent_alert_suppression_state';
@ -105,8 +107,6 @@ export interface StepDefineRuleProps extends RuleStepProps {
threatIndicesConfig: string[];
defaultSavedQuery?: SavedQuery;
form: FormHook<DefineStepRule>;
optionsSelected: EqlOptionsSelected;
setOptionsSelected: React.Dispatch<React.SetStateAction<EqlOptionsSelected>>;
indexPattern: DataViewBase;
isIndexPatternLoading: boolean;
isQueryBarValid: boolean;
@ -163,13 +163,11 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
isLoading,
isQueryBarValid,
isUpdateView = false,
optionsSelected,
queryBarSavedId,
queryBarTitle,
ruleType,
setIsQueryBarValid,
setIsThreatQueryBarValid,
setOptionsSelected,
shouldLoadQueryDynamically,
threatIndex,
threatIndicesConfig,
@ -220,15 +218,12 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
};
if (timelineQueryBar.query.language === 'eql') {
setRuleTypeCallback('eql', setQuery);
setOptionsSelected((prevOptions) => ({
...prevOptions,
...(eqlOptions != null ? eqlOptions : {}),
}));
setFieldValue('eqlOptions', eqlOptions ?? {});
} else {
setQuery();
}
},
[setFieldValue, setRuleTypeCallback, setOptionsSelected]
[setFieldValue, setRuleTypeCallback]
);
const { onOpenTimeline, loading: timelineQueryLoading } =
@ -719,43 +714,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
]
);
const onOptionsChange = useCallback(
(field: FieldsEqlOptions, value: string | undefined) => {
setOptionsSelected((prevOptions) => {
const newOptions = {
...prevOptions,
[field]: value,
};
setFieldValue('eqlOptions', newOptions);
return newOptions;
});
},
[setFieldValue, setOptionsSelected]
);
const optionsData = useMemo(
() =>
isEmpty(indexPattern.fields)
? {
keywordFields: [],
dateFields: [],
nonDateFields: [],
}
: {
keywordFields: (indexPattern.fields as FieldSpec[])
.filter((f) => f.esTypes?.includes('keyword'))
.map((f) => ({ label: f.name })),
dateFields: indexPattern.fields
.filter((f) => f.type === 'date')
.map((f) => ({ label: f.name })),
nonDateFields: indexPattern.fields
.filter((f) => f.type !== 'date')
.map((f) => ({ label: f.name })),
},
[indexPattern]
);
const selectRuleTypeProps = useMemo(
() => ({
describedByIds: ['detectionEngineStepDefineRuleType'],
@ -794,29 +752,18 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
<EuiSpacer size="s" />
{isEqlRule(ruleType) ? (
<>
<UseField
key="EqlQueryBar"
<EqlQueryEdit
key="eqlQueryBar"
path="queryBar"
component={EqlQueryBar}
componentProps={{
optionsData,
optionsSelected,
isSizeOptionDisabled: true,
onOptionsChange,
onValidityChange: setIsQueryBarValid,
idAria: 'detectionEngineStepDefineRuleEqlQueryBar',
isDisabled: isLoading,
isLoading: isIndexPatternLoading,
indexPattern,
showFilterBar: true,
dataTestSubj: 'detectionEngineStepDefineRuleEqlQueryBar',
}}
config={{
...schema.queryBar,
label: i18n.EQL_QUERY_BAR_LABEL,
}}
eqlOptionsPath="eqlOptions"
fieldsToValidateOnChange={ALERT_SUPPRESSION_FIELDS_FIELD_NAME}
required
showFilterBar
dataView={indexPattern}
loading={isIndexPatternLoading}
disabled={isLoading}
onValidityChange={setIsQueryBarValid}
/>
<UseField path="eqlOptions" component={HiddenField} />
</>
) : isEsqlRule(ruleType) ? (
EsqlQueryBarMemo

View file

@ -9,8 +9,7 @@ import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EuiText } from '@elastic/eui';
import React from 'react';
import { fromKueryExpression } from '@kbn/es-query';
import { debounceAsync } from '@kbn/securitysolution-utils';
import {
singleEntryThreat,
containsInvalidItems,
@ -27,32 +26,29 @@ import {
} from '../../../../../common/detection_engine/utils';
import { MAX_NUMBER_OF_NEW_TERMS_FIELDS } from '../../../../../common/constants';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
import type { FieldValueQueryBar } from '../query_bar';
import type { ERROR_CODE, FormSchema, ValidationFunc } from '../../../../shared_imports';
import { FIELD_TYPES, fieldValidators } from '../../../../shared_imports';
import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types';
import { DataSourceType } from '../../../../detections/pages/detection_engine/rules/types';
import { debounceAsync, eqlValidator } from '../eql_query_bar/validators';
import { esqlValidator } from '../../../rule_creation/logic/esql_validator';
import { dataViewIdValidatorFactory } from '../../validators/data_view_id_validator_factory';
import { indexPatternValidatorFactory } from '../../validators/index_pattern_validator_factory';
import { alertSuppressionFieldsValidatorFactory } from '../../validators/alert_suppression_fields_validator_factory';
import {
CUSTOM_QUERY_REQUIRED,
INVALID_CUSTOM_QUERY,
INDEX_HELPER_TEXT,
THREAT_MATCH_INDEX_HELPER_TEXT,
THREAT_MATCH_REQUIRED,
THREAT_MATCH_EMPTIES,
EQL_SEQUENCE_SUPPRESSION_GROUPBY_VALIDATION_TEXT,
} from './translations';
import { getQueryRequiredMessage } from './utils';
import {
ALERT_SUPPRESSION_DURATION_FIELD_NAME,
ALERT_SUPPRESSION_FIELDS_FIELD_NAME,
ALERT_SUPPRESSION_MISSING_FIELDS_FIELD_NAME,
} from '../../../rule_creation/components/alert_suppression_edit';
import * as alertSuppressionEditI81n from '../../../rule_creation/components/alert_suppression_edit/components/translations';
import { queryRequiredValidatorFactory } from '../../validators/query_required_validator_factory';
import { kueryValidatorFactory } from '../../validators/kuery_validator_factory';
export const schema: FormSchema<DefineStepRule> = {
index: {
@ -68,7 +64,7 @@ export const schema: FormSchema<DefineStepRule> = {
helpText: <EuiText size="xs">{INDEX_HELPER_TEXT}</EuiText>,
validations: [
{
validator: (...args: Parameters<ValidationFunc>) => {
validator: (...args) => {
const [{ formData }] = args;
if (
@ -94,7 +90,7 @@ export const schema: FormSchema<DefineStepRule> = {
fieldsToValidateOnChange: ['dataViewId'],
validations: [
{
validator: (...args: Parameters<ValidationFunc>) => {
validator: (...args) => {
const [{ formData }] = args;
if (isMlRule(formData.ruleType) || formData.dataSourceType !== DataSourceType.DataView) {
@ -122,55 +118,21 @@ export const schema: FormSchema<DefineStepRule> = {
fieldsToValidateOnChange: ['queryBar', ALERT_SUPPRESSION_FIELDS_FIELD_NAME],
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ value, path, formData }] = args;
const { query, filters, saved_id: savedId } = value as FieldValueQueryBar;
const needsValidation = !isMlRule(formData.ruleType);
if (!needsValidation) {
return undefined;
}
const isFieldEmpty = isEmpty(query.query as string) && isEmpty(filters);
if (!isFieldEmpty) {
return undefined;
}
if (savedId) {
validator: (...args) => {
const [{ value, formData }] = args;
if (isMlRule(formData.ruleType) || value.saved_id) {
// Ignore field validation error in this case.
// Instead, we show the error toast when saved query object does not exist.
// https://github.com/elastic/kibana/issues/159060
return undefined;
}
const message = getQueryRequiredMessage(formData.ruleType);
return { code: 'ERR_FIELD_MISSING', path, message };
},
},
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ value, path, formData }] = args;
const { query } = value as FieldValueQueryBar;
const needsValidation = !isMlRule(formData.ruleType);
if (!needsValidation) {
return;
}
if (!isEmpty(query.query as string) && query.language === 'kuery') {
try {
fromKueryExpression(query.query);
} catch (err) {
return {
code: 'ERR_FIELD_FORMAT',
path,
message: INVALID_CUSTOM_QUERY,
};
}
}
return queryRequiredValidatorFactory(formData.ruleType)(...args);
},
},
{
validator: debounceAsync(eqlValidator, 300),
validator: kueryValidatorFactory(),
},
{
validator: debounceAsync(esqlValidator, 300),
@ -509,49 +471,17 @@ export const schema: FormSchema<DefineStepRule> = {
),
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ value, path, formData }] = args;
const needsValidation = isThreatMatchRule(formData.ruleType);
if (!needsValidation) {
validator: (...args) => {
const [{ formData }] = args;
if (!isThreatMatchRule(formData.ruleType)) {
return;
}
const { query, filters } = value as FieldValueQueryBar;
return isEmpty(query.query as string) && isEmpty(filters)
? {
code: 'ERR_FIELD_MISSING',
path,
message: CUSTOM_QUERY_REQUIRED,
}
: undefined;
return queryRequiredValidatorFactory(formData.ruleType)(...args);
},
},
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ value, path, formData }] = args;
const needsValidation = isThreatMatchRule(formData.ruleType);
if (!needsValidation) {
return;
}
const { query } = value as FieldValueQueryBar;
if (!isEmpty(query.query as string) && query.language === 'kuery') {
try {
fromKueryExpression(query.query);
} catch (err) {
return {
code: 'ERR_FIELD_FORMAT',
path,
message: INVALID_CUSTOM_QUERY,
};
}
}
},
validator: kueryValidatorFactory(),
},
],
},

View file

@ -9,34 +9,6 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
export const CUSTOM_QUERY_REQUIRED = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldRequiredError',
{
defaultMessage: 'A custom query is required.',
}
);
export const EQL_QUERY_REQUIRED = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlQueryFieldRequiredError',
{
defaultMessage: 'An EQL query is required.',
}
);
export const ESQL_QUERY_REQUIRED = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError',
{
defaultMessage: 'An ES|QL query is required.',
}
);
export const INVALID_CUSTOM_QUERY = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldInvalidError',
{
defaultMessage: 'The KQL is invalid',
}
);
export const INDEX_HELPER_TEXT = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.indicesHelperDescription',
{
@ -66,13 +38,6 @@ export const QUERY_BAR_LABEL = i18n.translate(
}
);
export const EQL_QUERY_BAR_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.EqlQueryBarLabel',
{
defaultMessage: 'EQL query',
}
);
export const SAVED_QUERY_FORM_ROW_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.SavedQueryFormRowLabel',
{

View file

@ -5,13 +5,8 @@
* 2.0.
*/
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import type { FieldSpec } from '@kbn/data-plugin/common';
import { CUSTOM_QUERY_REQUIRED, EQL_QUERY_REQUIRED, ESQL_QUERY_REQUIRED } from './translations';
import { isEqlRule, isEsqlRule } from '../../../../../common/detection_engine/utils';
/**
* Filters out fields, that are not supported in terms aggregation.
* Terms aggregation supports limited number of types:
@ -25,18 +20,3 @@ export const getTermsAggregationFields = (fields: FieldSpec[]): FieldSpec[] =>
// binary types is excluded, as binary field has property aggregatable === false
const ALLOWED_AGGREGATABLE_FIELD_TYPES_SET = new Set(['string', 'number', 'ip', 'boolean']);
/**
* return query is required message depends on a rule type
*/
export const getQueryRequiredMessage = (ruleType: Type) => {
if (isEsqlRule(ruleType)) {
return ESQL_QUERY_REQUIRED;
}
if (isEqlRule(ruleType)) {
return EQL_QUERY_REQUIRED;
}
return CUSTOM_QUERY_REQUIRED;
};

View file

@ -17,7 +17,6 @@ import type {
} from '../../../detections/pages/detection_engine/rules/types';
import { useRuleFormsErrors } from './form';
import { transformEqlResponseErrorToValidationError } from '../components/eql_query_bar/validators';
import { ALERT_SUPPRESSION_FIELDS_FIELD_NAME } from '../../rule_creation/components/alert_suppression_edit';
const getFormWithErrorsMock = <T extends FormData = FormData>(fields: {
@ -33,13 +32,15 @@ describe('useRuleFormsErrors', () => {
it('should return blocking error in case of syntax validation error', async () => {
const { result } = renderHook(() => useRuleFormsErrors());
const validationError = transformEqlResponseErrorToValidationError({
code: EQL_ERROR_CODES.INVALID_SYNTAX,
messages: ["line 1:5: missing 'where' at 'demo'"],
});
const defineStepForm = getFormWithErrorsMock<DefineStepRule>({
queryBar: {
errors: [validationError],
errors: [
{
code: EQL_ERROR_CODES.INVALID_SYNTAX,
message: '',
messages: ["line 1:5: missing 'where' at 'demo'"],
},
],
},
});
@ -53,13 +54,17 @@ describe('useRuleFormsErrors', () => {
it('should return non-blocking error in case of missing data source validation error', async () => {
const { result } = renderHook(() => useRuleFormsErrors());
const validationError = transformEqlResponseErrorToValidationError({
code: EQL_ERROR_CODES.MISSING_DATA_SOURCE,
messages: ['index_not_found_exception Found 1 problem line -1:-1: Unknown index [*,-*]'],
});
const defineStepForm = getFormWithErrorsMock<DefineStepRule>({
queryBar: {
errors: [validationError],
errors: [
{
code: EQL_ERROR_CODES.MISSING_DATA_SOURCE,
message: '',
messages: [
'index_not_found_exception Found 1 problem line -1:-1: Unknown index [*,-*]',
],
},
],
},
});
@ -75,15 +80,17 @@ describe('useRuleFormsErrors', () => {
it('should return non-blocking error in case of missing data field validation error', async () => {
const { result } = renderHook(() => useRuleFormsErrors());
const validationError = transformEqlResponseErrorToValidationError({
code: EQL_ERROR_CODES.INVALID_EQL,
messages: [
'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]',
],
});
const defineStepForm = getFormWithErrorsMock<DefineStepRule>({
queryBar: {
errors: [validationError],
errors: [
{
code: EQL_ERROR_CODES.INVALID_EQL,
message: '',
messages: [
'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]',
],
},
],
},
});
@ -99,13 +106,15 @@ describe('useRuleFormsErrors', () => {
it('should return non-blocking error in case of failed request error', async () => {
const { result } = renderHook(() => useRuleFormsErrors());
const validationError = transformEqlResponseErrorToValidationError({
code: EQL_ERROR_CODES.FAILED_REQUEST,
error: new Error('Some internal error'),
});
const defineStepForm = getFormWithErrorsMock<DefineStepRule>({
queryBar: {
errors: [validationError],
errors: [
{
code: EQL_ERROR_CODES.FAILED_REQUEST,
message: 'An error occurred while validating your EQL query',
error: new Error('Some internal error'),
},
],
},
});
@ -121,13 +130,15 @@ describe('useRuleFormsErrors', () => {
it('should return blocking and non-blocking errors', async () => {
const { result } = renderHook(() => useRuleFormsErrors());
const validationError = transformEqlResponseErrorToValidationError({
code: EQL_ERROR_CODES.MISSING_DATA_SOURCE,
messages: ['Missing data source'],
});
const defineStepForm = getFormWithErrorsMock<DefineStepRule>({
queryBar: {
errors: [validationError],
errors: [
{
code: EQL_ERROR_CODES.MISSING_DATA_SOURCE,
message: '',
messages: ['Missing data source'],
},
],
},
});
const aboutStepForm = getFormWithErrorsMock<AboutStepRule>({

View file

@ -19,7 +19,6 @@ import { useKibana } from '../../../common/lib/kibana';
import type { FormHook, ValidationError } from '../../../shared_imports';
import { useForm, useFormData } from '../../../shared_imports';
import { schema as defineRuleSchema } from '../components/step_define_rule/schema';
import type { EqlOptionsSelected } from '../../../../common/search_strategy';
import {
schema as aboutRuleSchema,
threatMatchAboutSchema,
@ -53,20 +52,14 @@ export const useRuleForms = ({
options: { stripEmptyFields: false },
schema: defineRuleSchema,
});
const [eqlOptionsSelected, setEqlOptionsSelected] = useState<EqlOptionsSelected>(
defineStepDefault.eqlOptions
);
const [defineStepFormData] = useFormData<DefineStepRule | {}>({
form: defineStepForm,
});
// FormData doesn't populate on the first render, so we use the defaultValue if the formData
// doesn't have what we wanted
const defineStepData = useMemo(
() =>
'index' in defineStepFormData
? { ...defineStepFormData, eqlOptions: eqlOptionsSelected }
: defineStepDefault,
[defineStepDefault, defineStepFormData, eqlOptionsSelected]
() => ('index' in defineStepFormData ? defineStepFormData : defineStepDefault),
[defineStepDefault, defineStepFormData]
);
// ABOUT STEP FORM
@ -118,8 +111,6 @@ export const useRuleForms = ({
scheduleStepData,
actionsStepForm,
actionsStepData,
eqlOptionsSelected,
setEqlOptionsSelected,
};
};

View file

@ -171,8 +171,6 @@ const CreateRulePageComponent: React.FC = () => {
scheduleStepData,
actionsStepForm,
actionsStepData,
eqlOptionsSelected,
setEqlOptionsSelected,
} = useRuleForms({
defineStepDefault,
aboutStepDefault: stepAboutDefaultValue,
@ -392,10 +390,9 @@ const CreateRulePageComponent: React.FC = () => {
const createRuleFromFormData = useCallback(
async (enabled: boolean) => {
const localDefineStepData: DefineStepRule = defineFieldsTransform({
...defineStepForm.getFormData(),
eqlOptions: eqlOptionsSelected,
});
const localDefineStepData: DefineStepRule = defineFieldsTransform(
defineStepForm.getFormData()
);
const localAboutStepData = aboutStepForm.getFormData();
const localScheduleStepData = scheduleStepForm.getFormData();
const localActionsStepData = actionsStepForm.getFormData();
@ -435,7 +432,6 @@ const CreateRulePageComponent: React.FC = () => {
createRule,
defineFieldsTransform,
defineStepForm,
eqlOptionsSelected,
navigateToApp,
ruleType,
scheduleStepForm,
@ -556,8 +552,6 @@ const CreateRulePageComponent: React.FC = () => {
indicesConfig={indicesConfig}
threatIndicesConfig={threatIndicesConfig}
form={defineStepForm}
optionsSelected={eqlOptionsSelected}
setOptionsSelected={setEqlOptionsSelected}
indexPattern={indexPattern}
isIndexPatternLoading={isIndexPatternLoading}
isQueryBarValid={isQueryBarValid}
@ -588,7 +582,6 @@ const CreateRulePageComponent: React.FC = () => {
defineStepData,
memoizedIndex,
defineStepForm,
eqlOptionsSelected,
indexPattern,
indicesConfig,
isCreateRuleLoading,
@ -596,7 +589,6 @@ const CreateRulePageComponent: React.FC = () => {
isQueryBarValid,
loading,
memoDefineStepReadOnly,
setEqlOptionsSelected,
threatIndicesConfig,
]
);

View file

@ -131,8 +131,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
scheduleStepData,
actionsStepForm,
actionsStepData,
eqlOptionsSelected,
setEqlOptionsSelected,
} = useRuleForms({
defineStepDefault: defineRuleData,
aboutStepDefault: aboutRuleData,
@ -232,8 +230,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
threatIndicesConfig={threatIndicesConfig}
defaultSavedQuery={savedQuery}
form={defineStepForm}
optionsSelected={eqlOptionsSelected}
setOptionsSelected={setEqlOptionsSelected}
key="defineStep"
indexPattern={indexPattern}
isIndexPatternLoading={isIndexPatternLoading}
@ -355,8 +351,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
threatIndicesConfig,
savedQuery,
defineStepForm,
eqlOptionsSelected,
setEqlOptionsSelected,
indexPattern,
isIndexPatternLoading,
isQueryBarValid,

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { fromKueryExpression } from '@kbn/es-query';
import type { FormData, ValidationFunc } from '../../../shared_imports';
import type { FieldValueQueryBar } from '../components/query_bar';
export function kueryValidatorFactory(): ValidationFunc<FormData, string, FieldValueQueryBar> {
return (...args) => {
const [{ path, value }] = args;
if (isEmpty(value.query.query) || value.query.language !== 'kuery') {
return;
}
try {
fromKueryExpression(value.query.query);
} catch (err) {
return {
code: 'ERR_FIELD_FORMAT',
path,
message: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldInvalidError',
{
defaultMessage: 'The KQL is invalid',
}
),
};
}
};
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import type { RuleType } from '@kbn/securitysolution-rules';
import type { FormData, ValidationFunc } from '../../../shared_imports';
import { isEqlRule, isEsqlRule } from '../../../../common/detection_engine/utils';
import type { FieldValueQueryBar } from '../components/query_bar';
export function queryRequiredValidatorFactory(
ruleType: RuleType
): ValidationFunc<FormData, string, FieldValueQueryBar> {
return (...args) => {
const [{ path, value }] = args;
if (isEmpty(value.query.query as string) && isEmpty(value.filters)) {
return {
code: 'ERR_FIELD_MISSING',
path,
message: getErrorMessage(ruleType),
};
}
};
}
function getErrorMessage(ruleType: RuleType): string {
if (isEsqlRule(ruleType)) {
return i18n.translate(
'xpack.securitySolution.ruleManagement.ruleCreation.validation.query.esqlQueryFieldRequiredError',
{
defaultMessage: 'An ES|QL query is required.',
}
);
}
if (isEqlRule(ruleType)) {
return i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlQueryFieldRequiredError',
{
defaultMessage: 'An EQL query is required.',
}
);
}
return i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldRequiredError',
{
defaultMessage: 'A custom query is required.',
}
);
}

View file

@ -34,9 +34,6 @@ export const DEFINITION_UPGRADE_FIELD_ORDER: Array<keyof DiffableAllFields> = [
'type',
'kql_query',
'eql_query',
'event_category_override',
'timestamp_field',
'tiebreaker_field',
'esql_query',
'anomaly_threshold',
'machine_learning_job_id',

View file

@ -92,16 +92,16 @@ describe('PerFieldRuleDiffTab', () => {
});
describe('Undefined values are displayed with empty diffs', () => {
test('Displays only an updated field value when changed from undefined', () => {
test('Displays only an updated field value when changed from an empty value', () => {
const mockData: PartialRuleDiff = {
...ruleFieldsDiffMock,
fields: {
timestamp_field: {
name: {
...ruleFieldsDiffBaseFieldsMock,
base_version: undefined,
current_version: undefined,
merged_version: 'new timestamp field',
target_version: 'new timestamp field',
current_version: '',
merged_version: 'new name',
target_version: 'new name',
},
},
};
@ -109,19 +109,19 @@ describe('PerFieldRuleDiffTab', () => {
const diffContent = wrapper.getByTestId('ruleUpgradePerFieldDiffContent').textContent;
// Only the new timestamp field should be displayed
expect(diffContent).toEqual('+new timestamp field');
expect(diffContent).toEqual('+new name');
});
test('Displays only an outdated field value when incoming update is undefined', () => {
test('Displays only an outdated field value when incoming update is an empty value', () => {
const mockData: PartialRuleDiff = {
...ruleFieldsDiffMock,
fields: {
timestamp_field: {
name: {
...ruleFieldsDiffBaseFieldsMock,
base_version: 'old timestamp field',
current_version: 'old timestamp field',
merged_version: undefined,
target_version: undefined,
base_version: 'old name',
current_version: 'old name',
merged_version: '',
target_version: '',
},
},
};
@ -129,7 +129,7 @@ describe('PerFieldRuleDiffTab', () => {
const diffContent = wrapper.getByTestId('ruleUpgradePerFieldDiffContent').textContent;
// Only the old timestamp_field should be displayed
expect(diffContent).toEqual('-old timestamp field');
expect(diffContent).toEqual('-old name');
});
});
@ -144,13 +144,6 @@ describe('PerFieldRuleDiffTab', () => {
merged_version: 'new setup',
target_version: 'new setup',
},
timestamp_field: {
...ruleFieldsDiffBaseFieldsMock,
base_version: undefined,
current_version: undefined,
merged_version: 'new timestamp',
target_version: 'new timestamp',
},
name: {
...ruleFieldsDiffBaseFieldsMock,
base_version: 'old name',
@ -166,11 +159,11 @@ describe('PerFieldRuleDiffTab', () => {
const sectionLabels = matchedSectionElements.map((element) => element.textContent);
// Schedule doesn't have any fields in the diff and shouldn't be displayed
expect(sectionLabels).toEqual(['About', 'Definition', 'Setup guide']);
expect(sectionLabels).toEqual(['About', 'Setup guide']);
const matchedFieldElements = wrapper.queryAllByTestId('ruleUpgradePerFieldDiffLabel');
const fieldLabels = matchedFieldElements.map((element) => element.textContent);
expect(fieldLabels).toEqual(['Name', 'Timestamp Field', 'Setup']);
expect(fieldLabels).toEqual(['Name', 'Setup']);
});
});

View file

@ -27,6 +27,7 @@ import type { DataView } from '@kbn/data-views-plugin/public';
import { FilterItems } from '@kbn/unified-search-plugin/public';
import type {
AlertSuppressionMissingFieldsStrategy,
EqlOptionalFields,
RequiredFieldArray,
RuleResponse,
Threshold as ThresholdType,
@ -59,6 +60,11 @@ import {
} from './rule_definition_section.styles';
import { getQueryLanguageLabel } from './helpers';
import { useDefaultIndexPattern } from '../../hooks/use_default_index_pattern';
import {
EQL_OPTIONS_EVENT_CATEGORY_FIELD_LABEL,
EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_LABEL,
EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_LABEL,
} from '../../../rule_creation/components/eql_query_edit/translations';
interface SavedQueryNameProps {
savedQueryName: string;
@ -562,6 +568,51 @@ const prepareDefinitionSectionListItems = (
}
}
if ((rule as EqlOptionalFields).event_category_override) {
definitionSectionListItems.push({
title: (
<span data-test-subj="eqlOptionsEventCategoryOverrideTitle">
{EQL_OPTIONS_EVENT_CATEGORY_FIELD_LABEL}
</span>
),
description: (
<span data-test-subj="eqlOptionsEventCategoryOverrideValue">
{(rule as EqlOptionalFields).event_category_override}
</span>
),
});
}
if ((rule as EqlOptionalFields).tiebreaker_field) {
definitionSectionListItems.push({
title: (
<span data-test-subj="eqlOptionsTiebreakerFieldTitle">
{EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_LABEL}
</span>
),
description: (
<span data-test-subj="eqlOptionsEventTiebreakerFieldValue">
{(rule as EqlOptionalFields).tiebreaker_field}
</span>
),
});
}
if ((rule as EqlOptionalFields).timestamp_field) {
definitionSectionListItems.push({
title: (
<span data-test-subj="eqlOptionsTimestampFieldTitle">
{EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_LABEL}
</span>
),
description: (
<span data-test-subj="eqlOptionsTimestampFieldValue">
{(rule as EqlOptionalFields).timestamp_field}
</span>
),
});
}
if (rule.type) {
definitionSectionListItems.push({
title: i18n.RULE_TYPE_FIELD_LABEL,

View file

@ -15,26 +15,25 @@ export const getSubfieldChangesForEqlQuery = (
): SubfieldChange[] => {
const changes: SubfieldChange[] = [];
const oldQuery = stringifyToSortedJson(oldFieldValue?.query);
const newQuery = stringifyToSortedJson(newFieldValue?.query);
const subFieldNames: Array<keyof DiffableAllFields['eql_query']> = [
'query',
'filters',
'event_category_override',
'tiebreaker_field',
'timestamp_field',
];
if (oldQuery !== newQuery) {
changes.push({
subfieldName: 'query',
oldSubfieldValue: oldQuery,
newSubfieldValue: newQuery,
});
}
for (const subFieldName of subFieldNames) {
const oldValue = stringifyToSortedJson(oldFieldValue?.[subFieldName]);
const newValue = stringifyToSortedJson(newFieldValue?.[subFieldName]);
const oldFilters = stringifyToSortedJson(oldFieldValue?.filters);
const newFilters = stringifyToSortedJson(newFieldValue?.filters);
if (oldFilters !== newFilters) {
changes.push({
subfieldName: 'filters',
oldSubfieldValue: oldFilters,
newSubfieldValue: newFilters,
});
if (newValue !== oldValue) {
changes.push({
subfieldName: subFieldName,
oldSubfieldValue: oldValue,
newSubfieldValue: newValue,
});
}
}
return changes;

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import type { UpgradeableCustomQueryFields } from '../../../../model/prebuilt_rule_upgrade/fields';
import { KqlQueryEditForm } from './fields/kql_query';
import { DataSourceEditForm } from './fields/data_source';
@ -24,6 +25,6 @@ export function CustomQueryRuleFieldEdit({ fieldName }: CustomQueryRuleFieldEdit
case 'alert_suppression':
return <AlertSuppressionEditForm />;
default:
return null; // Will be replaced with `assertUnreachable(fieldName)` once all fields are implemented
return assertUnreachable(fieldName);
}
}

View file

@ -6,9 +6,11 @@
*/
import React from 'react';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import type { UpgradeableEqlFields } from '../../../../model/prebuilt_rule_upgrade/fields';
import { DataSourceEditForm } from './fields/data_source';
import { AlertSuppressionEditForm } from './fields/alert_suppression';
import { EqlQueryEditForm } from './fields/eql_query';
interface EqlRuleFieldEditProps {
fieldName: UpgradeableEqlFields;
@ -16,11 +18,13 @@ interface EqlRuleFieldEditProps {
export function EqlRuleFieldEdit({ fieldName }: EqlRuleFieldEditProps) {
switch (fieldName) {
case 'eql_query':
return <EqlQueryEditForm />;
case 'data_source':
return <DataSourceEditForm />;
case 'alert_suppression':
return <AlertSuppressionEditForm />;
default:
return null; // Will be replaced with `assertUnreachable(fieldName)` once all fields are implemented
return assertUnreachable(fieldName);
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { DataViewBase } from '@kbn/es-query';
import { EqlQueryEdit } from '../../../../../../../rule_creation/components/eql_query_edit';
import type { RuleFieldEditComponentProps } from '../rule_field_edit_component_props';
import { useDiffableRuleDataView } from '../hooks/use_diffable_rule_data_view';
export function EqlQueryEditAdapter({
finalDiffableRule,
}: RuleFieldEditComponentProps): JSX.Element | null {
const { dataView, isLoading } = useDiffableRuleDataView(finalDiffableRule);
// Wait for dataView to be defined to trigger validation with the correct index patterns
if (!dataView) {
return null;
}
return (
<EqlQueryEdit
path="eqlQuery"
eqlOptionsPath="eqlOptions"
required
dataView={dataView ?? DEFAULT_DATA_VIEW_BASE}
loading={isLoading}
disabled={isLoading}
skipEqlValidation
/>
);
}
const DEFAULT_DATA_VIEW_BASE: DataViewBase = {
title: '',
fields: [],
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { Filter } from '@kbn/es-query';
import type { EqlOptions } from '@kbn/timelines-plugin/common';
import type { FormData, FormSchema } from '../../../../../../../../shared_imports';
import { RuleFieldEditFormWrapper } from '../rule_field_edit_form_wrapper';
import type { FieldValueQueryBar } from '../../../../../../../rule_creation_ui/components/query_bar';
import {
type DiffableRule,
RuleEqlQuery,
QueryLanguageEnum,
} from '../../../../../../../../../common/api/detection_engine';
import { EqlQueryEditAdapter } from './eql_query_edit_adapter';
export function EqlQueryEditForm(): JSX.Element {
return (
<RuleFieldEditFormWrapper
component={EqlQueryEditAdapter}
ruleFieldFormSchema={kqlQuerySchema}
deserializer={deserializer}
serializer={serializer}
/>
);
}
const kqlQuerySchema = {} as FormSchema<{
eqlQuery: RuleEqlQuery;
}>;
function deserializer(
fieldValue: FormData,
finalDiffableRule: DiffableRule
): {
eqlQuery: FieldValueQueryBar;
eqlOptions: EqlOptions;
} {
const parsedEqlQuery =
'eql_query' in finalDiffableRule
? RuleEqlQuery.parse(fieldValue.eql_query)
: {
query: '',
language: QueryLanguageEnum.eql,
filters: [],
};
return {
eqlQuery: {
query: {
query: parsedEqlQuery.query,
language: parsedEqlQuery.language,
},
// cast to Filter since RuleEqlQuery checks it's an array
// potentially it might be incompatible type
filters: parsedEqlQuery.filters as Filter[],
saved_id: null,
},
eqlOptions: {
eventCategoryField: parsedEqlQuery.event_category_override,
timestampField: parsedEqlQuery.timestamp_field,
tiebreakerField: parsedEqlQuery.tiebreaker_field,
},
};
}
function serializer(formData: FormData): {
eql_query: RuleEqlQuery;
} {
const formValue = formData as { eqlQuery: FieldValueQueryBar; eqlOptions: EqlOptions };
return {
eql_query: {
query: formValue.eqlQuery.query.query as string,
language: QueryLanguageEnum.eql,
filters: formValue.eqlQuery.filters,
event_category_override: formValue.eqlOptions.eventCategoryField,
timestamp_field: formValue.eqlOptions.timestampField,
tiebreaker_field: formValue.eqlOptions.tiebreakerField,
},
};
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './eql_query_edit_form';

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import type { UpgradeableSavedQueryFields } from '../../../../model/prebuilt_rule_upgrade/fields';
import { KqlQueryEditForm } from './fields/kql_query';
import { DataSourceEditForm } from './fields/data_source';
@ -24,6 +25,6 @@ export function SavedQueryRuleFieldEdit({ fieldName }: SavedQueryRuleFieldEditPr
case 'alert_suppression':
return <AlertSuppressionEditForm />;
default:
return null; // Will be replaced with `assertUnreachable(fieldName)` once all fields are implemented
return assertUnreachable(fieldName);
}
}

View file

@ -12,9 +12,6 @@ import { EqlQueryReadOnly } from './fields/eql_query/eql_query';
import { TypeReadOnly } from './fields/type/type';
import { AlertSuppressionReadOnly } from './fields/alert_suppression/alert_suppression';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import { EventCategoryOverrideReadOnly } from './fields/event_category_override/event_category_override';
import { TimestampFieldReadOnly } from './fields/timestamp_field/timestamp_field';
import { TiebreakerFieldReadOnly } from './fields/tiebreaker_field/tiebreaker_field';
interface EqlRuleFieldReadOnlyProps {
fieldName: keyof DiffableEqlFields;
@ -39,16 +36,6 @@ export function EqlRuleFieldReadOnly({ fieldName, finalDiffableRule }: EqlRuleFi
dataSource={finalDiffableRule.data_source}
/>
);
case 'event_category_override':
return (
<EventCategoryOverrideReadOnly
eventCategoryOverride={finalDiffableRule.event_category_override}
/>
);
case 'tiebreaker_field':
return <TiebreakerFieldReadOnly tiebreakerField={finalDiffableRule.tiebreaker_field} />;
case 'timestamp_field':
return <TimestampFieldReadOnly timestampField={finalDiffableRule.timestamp_field} />;
case 'type':
return <TypeReadOnly type={finalDiffableRule.type} />;
default:

View file

@ -6,11 +6,11 @@
*/
import React from 'react';
import { EuiDescriptionList } from '@elastic/eui';
import { EuiDescriptionList, EuiText } from '@elastic/eui';
import type { EuiDescriptionListProps } from '@elastic/eui';
import type {
RuleDataSource,
RuleEqlQuery,
import {
type RuleDataSource,
type RuleEqlQuery,
} from '../../../../../../../../../common/api/detection_engine';
import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations';
import { Query, Filters } from '../../../../rule_definition_section';
@ -38,5 +38,26 @@ export function EqlQueryReadOnly({ eqlQuery, dataSource }: EqlQueryReadOnlyProps
});
}
if (eqlQuery.event_category_override) {
listItems.push({
title: descriptionStepI18n.EQL_EVENT_CATEGORY_FIELD_LABEL,
description: <EuiText size="s">{eqlQuery.event_category_override}</EuiText>,
});
}
if (eqlQuery.tiebreaker_field) {
listItems.push({
title: descriptionStepI18n.EQL_TIEBREAKER_FIELD_LABEL,
description: <EuiText size="s">{eqlQuery.tiebreaker_field}</EuiText>,
});
}
if (eqlQuery.timestamp_field) {
listItems.push({
title: descriptionStepI18n.EQL_TIMESTAMP_FIELD_LABEL,
description: <EuiText size="s">{eqlQuery.timestamp_field}</EuiText>,
});
}
return <EuiDescriptionList listItems={listItems} />;
}

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { Story } from '@storybook/react';
import { EventCategoryOverrideReadOnly } from './event_category_override';
import { FieldReadOnly } from '../../field_readonly';
import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine';
import { mockEqlRule } from '../../storybook/mocks';
import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers';
export default {
component: EventCategoryOverrideReadOnly,
title:
'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/event_category_override',
};
interface TemplateProps {
finalDiffableRule: DiffableRule;
}
const Template: Story<TemplateProps> = (args) => {
return (
<ThreeWayDiffStorybookProviders finalDiffableRule={args.finalDiffableRule}>
<FieldReadOnly fieldName="event_category_override" />
</ThreeWayDiffStorybookProviders>
);
};
export const Default = Template.bind({});
Default.args = {
finalDiffableRule: mockEqlRule({
event_category_override: 'event.action',
}),
};

View file

@ -1,42 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiDescriptionList, EuiText } from '@elastic/eui';
import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations';
import type { EventCategoryOverride as EventCategoryOverrideType } from '../../../../../../../../../common/api/detection_engine';
interface EventCategoryOverrideReadOnlyProps {
eventCategoryOverride?: EventCategoryOverrideType;
}
export function EventCategoryOverrideReadOnly({
eventCategoryOverride,
}: EventCategoryOverrideReadOnlyProps) {
if (!eventCategoryOverride) {
return null;
}
return (
<EuiDescriptionList
listItems={[
{
title: descriptionStepI18n.EQL_EVENT_CATEGORY_FIELD_LABEL,
description: <EventCategoryOverride eventCategoryOverride={eventCategoryOverride} />,
},
]}
/>
);
}
interface EventCategoryOverrideProps {
eventCategoryOverride: EventCategoryOverrideType;
}
function EventCategoryOverride({ eventCategoryOverride }: EventCategoryOverrideProps) {
return <EuiText size="s">{eventCategoryOverride}</EuiText>;
}

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { Story } from '@storybook/react';
import { TiebreakerFieldReadOnly } from './tiebreaker_field';
import { FieldReadOnly } from '../../field_readonly';
import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine';
import { mockEqlRule } from '../../storybook/mocks';
import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers';
export default {
component: TiebreakerFieldReadOnly,
title:
'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/tiebreaker_field',
};
interface TemplateProps {
finalDiffableRule: DiffableRule;
}
const Template: Story<TemplateProps> = (args) => {
return (
<ThreeWayDiffStorybookProviders finalDiffableRule={args.finalDiffableRule}>
<FieldReadOnly fieldName="tiebreaker_field" />
</ThreeWayDiffStorybookProviders>
);
};
export const Default = Template.bind({});
Default.args = {
finalDiffableRule: mockEqlRule({
tiebreaker_field: 'process.name',
}),
};

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiDescriptionList, EuiText } from '@elastic/eui';
import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations';
import type { TiebreakerField as TiebreakerFieldType } from '../../../../../../../../../common/api/detection_engine';
interface TiebreakerFieldReadOnlyProps {
tiebreakerField?: TiebreakerFieldType;
}
export function TiebreakerFieldReadOnly({ tiebreakerField }: TiebreakerFieldReadOnlyProps) {
if (!tiebreakerField) {
return null;
}
return (
<EuiDescriptionList
listItems={[
{
title: descriptionStepI18n.EQL_TIEBREAKER_FIELD_LABEL,
description: <TiebreakerField tiebreakerField={tiebreakerField} />,
},
]}
/>
);
}
interface TiebreakerFieldProps {
tiebreakerField: TiebreakerFieldType;
}
function TiebreakerField({ tiebreakerField }: TiebreakerFieldProps) {
return <EuiText size="s">{tiebreakerField}</EuiText>;
}

View file

@ -1,39 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { Story } from '@storybook/react';
import { TimestampFieldReadOnly } from './timestamp_field';
import { FieldReadOnly } from '../../field_readonly';
import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine';
import { mockEqlRule } from '../../storybook/mocks';
import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers';
export default {
component: TimestampFieldReadOnly,
title: 'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/timestamp_field',
};
interface TemplateProps {
finalDiffableRule: DiffableRule;
}
const Template: Story<TemplateProps> = (args) => {
return (
<ThreeWayDiffStorybookProviders finalDiffableRule={args.finalDiffableRule}>
<FieldReadOnly fieldName="timestamp_field" />
</ThreeWayDiffStorybookProviders>
);
};
export const Default = Template.bind({});
Default.args = {
finalDiffableRule: mockEqlRule({
timestamp_field: 'event.created',
}),
};

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiDescriptionList, EuiText } from '@elastic/eui';
import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations';
import type { TimestampField as TimestampFieldType } from '../../../../../../../../../common/api/detection_engine';
interface TimestampFieldReadOnlyProps {
timestampField?: TimestampFieldType;
}
export function TimestampFieldReadOnly({ timestampField }: TimestampFieldReadOnlyProps) {
if (!timestampField) {
return null;
}
return (
<EuiDescriptionList
listItems={[
{
title: descriptionStepI18n.EQL_TIMESTAMP_FIELD_LABEL,
description: <TimestampField timestampField={timestampField} />,
},
]}
/>
);
}
interface TimestampFieldProps {
timestampField: TimestampFieldType;
}
function TimestampField({ timestampField }: TimestampFieldProps) {
return <EuiText size="s">{timestampField}</EuiText>;
}

View file

@ -355,7 +355,6 @@ describe('alert actions', () => {
eventCategoryField: 'event.category',
query: '',
size: 100,
tiebreakerField: '',
timestampField: '@timestamp',
},
eventIdToNoteIds: {},

View file

@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { i18n } from '@kbn/i18n';
import type { EqlOptionsSelected } from '@kbn/timelines-plugin/common';
import type { EqlOptions } from '@kbn/timelines-plugin/common';
import { convertKueryToElasticSearchQuery } from '../../../../common/lib/kuery';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useSourcererDataView } from '../../../../sourcerer/containers';
@ -37,7 +37,7 @@ export type SetRuleQuery = ({
}: {
index: string[];
queryBar: FieldValueQueryBar;
eqlOptions?: EqlOptionsSelected;
eqlOptions?: EqlOptions;
}) => void;
export const useRuleFromTimeline = (setRuleQuery: SetRuleQuery): RuleFromTimeline => {

View file

@ -50,7 +50,7 @@ import type {
RequiredFieldInput,
} from '../../../../../common/api/detection_engine/model/rule_schema';
import type { SortOrder } from '../../../../../common/api/detection_engine';
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
import type { EqlOptions } from '../../../../../common/search_strategy';
import type {
RuleResponseAction,
ResponseAction,
@ -164,7 +164,7 @@ export interface DefineStepRule {
threatIndex: ThreatIndex;
threatQueryBar: FieldValueQueryBar;
threatMapping: ThreatMapping;
eqlOptions: EqlOptionsSelected;
eqlOptions: EqlOptions;
dataSourceType: DataSourceType;
newTermsFields: string[];
historyWindowSize: string;

View file

@ -5,36 +5,28 @@
* 2.0.
*/
import { isEmpty, isEqual } from 'lodash';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isEqual } from 'lodash';
import React, { memo, useCallback, useEffect, useMemo } from 'react';
import { EuiOutsideClickDetector } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { css } from '@emotion/css';
import type {
EqlOptionsSelected,
FieldsEqlOptions,
} from '../../../../../../common/search_strategy';
import type { EqlOptions } from '../../../../../../common/search_strategy';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { SourcererScopeName } from '../../../../../sourcerer/store/model';
import { EqlQueryBar } from '../../../../../detection_engine/rule_creation_ui/components/eql_query_bar';
import {
debounceAsync,
eqlValidator,
} from '../../../../../detection_engine/rule_creation_ui/components/eql_query_bar/validators';
import { EqlQueryEdit } from '../../../../../detection_engine/rule_creation/components/eql_query_edit';
import type { FieldValueQueryBar } from '../../../../../detection_engine/rule_creation_ui/components/query_bar';
import type { FormSchema } from '../../../../../shared_imports';
import { Form, UseField, useForm, useFormData } from '../../../../../shared_imports';
import type { FormSchema, FormSubmitHandler } from '../../../../../shared_imports';
import { Form, UseField, useForm } from '../../../../../shared_imports';
import { timelineActions } from '../../../../store';
import * as i18n from '../translations';
import { getEqlOptions } from './selectors';
interface TimelineEqlQueryBar {
index: string[];
eqlQueryBar: FieldValueQueryBar;
eqlOptions: EqlOptionsSelected;
eqlOptions: EqlOptions;
}
const defaultValues = {
@ -55,13 +47,6 @@ const schema: FormSchema<TimelineEqlQueryBar> = {
eqlOptions: {
fieldsToValidateOnChange: ['eqlOptions', 'eqlQueryBar'],
},
eqlQueryBar: {
validations: [
{
validator: debounceAsync(eqlValidator, 300),
},
],
},
};
const hiddenUseFieldClassName = css`
@ -71,15 +56,12 @@ const hiddenUseFieldClassName = css`
// eslint-disable-next-line react/display-name
export const EqlQueryBarTimeline = memo(({ timelineId }: { timelineId: string }) => {
const dispatch = useDispatch();
const isInit = useRef(true);
const [isQueryBarValid, setIsQueryBarValid] = useState(false);
const [isQueryBarValidating, setIsQueryBarValidating] = useState(false);
const getOptionsSelected = useMemo(() => getEqlOptions(), []);
const optionsSelected = useDeepEqualSelector((state) => getOptionsSelected(state, timelineId));
const eqlOptions = useDeepEqualSelector((state) => getOptionsSelected(state, timelineId));
const {
loading: indexPatternsLoading,
indexPattern,
sourcererDataView,
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline);
@ -89,131 +71,117 @@ export const EqlQueryBarTimeline = memo(({ timelineId }: { timelineId: string })
index: [...selectedPatterns].sort(),
eqlQueryBar: {
...defaultValues.eqlQueryBar,
query: { query: optionsSelected.query ?? '', language: 'eql' },
query: { query: eqlOptions.query ?? '', language: 'eql' },
},
eqlOptions,
}),
[optionsSelected.query, selectedPatterns]
[eqlOptions, selectedPatterns]
);
const handleSubmit = useCallback<FormSubmitHandler<TimelineEqlQueryBar>>(
async (formData, isValid) => {
if (!isValid) {
return;
}
if (eqlOptions.query !== `${formData.eqlQueryBar.query.query}`) {
dispatch(
timelineActions.updateEqlOptions({
id: timelineId,
field: 'query',
value: `${formData.eqlQueryBar.query.query}`,
})
);
}
for (const fieldName of Object.keys(formData.eqlOptions) as Array<
keyof typeof formData.eqlOptions
>) {
if (formData.eqlOptions[fieldName] !== eqlOptions[fieldName]) {
dispatch(
timelineActions.updateEqlOptions({
id: timelineId,
field: fieldName,
value: formData.eqlOptions[fieldName],
})
);
}
}
},
[dispatch, timelineId, eqlOptions]
);
const { form } = useForm<TimelineEqlQueryBar>({
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
onSubmit: handleSubmit,
});
const { getFields, setFieldValue } = form;
const { getFields } = form;
const handleOutsideEqlQueryEditClick = useCallback(() => form.submit(), [form]);
const onOptionsChange = useCallback(
(field: FieldsEqlOptions, value: string | undefined) => {
dispatch(
timelineActions.updateEqlOptions({
id: timelineId,
field,
value,
})
);
setFieldValue('eqlOptions', { ...optionsSelected, [field]: value });
},
[dispatch, optionsSelected, setFieldValue, timelineId]
);
// Reset the form when new EQL Query came from the state
useEffect(() => {
getFields().eqlQueryBar.setValue({
...defaultValues.eqlQueryBar,
query: { query: eqlOptions.query ?? '', language: 'eql' },
});
}, [getFields, eqlOptions.query]);
const [{ eqlQueryBar: formEqlQueryBar }] = useFormData<TimelineEqlQueryBar>({
form,
watch: ['eqlQueryBar'],
});
const prevEqlQuery = useRef<TimelineEqlQueryBar['eqlQueryBar']['query']['query']>('');
const optionsData = useMemo(
() =>
isEmpty(indexPattern.fields)
? {
keywordFields: [],
dateFields: [],
nonDateFields: [],
}
: {
keywordFields: indexPattern.fields
.filter((f) => f.esTypes?.includes('keyword'))
.map((f) => ({ label: f.name })),
dateFields: indexPattern.fields
.filter((f) => f.type === 'date')
.map((f) => ({ label: f.name })),
nonDateFields: indexPattern.fields
.filter((f) => f.type !== 'date')
.map((f) => ({ label: f.name })),
},
[indexPattern]
);
// Reset the form when new EQL Options came from the state
useEffect(() => {
getFields().eqlOptions.setValue({
eventCategoryField: eqlOptions.eventCategoryField,
tiebreakerField: eqlOptions.tiebreakerField,
timestampField: eqlOptions.timestampField,
size: eqlOptions.size,
});
}, [
getFields,
eqlOptions.eventCategoryField,
eqlOptions.tiebreakerField,
eqlOptions.timestampField,
eqlOptions.size,
]);
useEffect(() => {
const { index: indexField } = getFields();
const newIndexValue = [...selectedPatterns].sort();
const indexFieldValue = (indexField.value as string[]).sort();
if (!isEqual(indexFieldValue, newIndexValue)) {
indexField.setValue(newIndexValue);
}
}, [getFields, selectedPatterns]);
useEffect(() => {
const { eqlQueryBar } = getFields();
if (isInit.current) {
isInit.current = false;
setIsQueryBarValidating(true);
eqlQueryBar.setValue({
...defaultValues.eqlQueryBar,
query: { query: optionsSelected.query ?? '', language: 'eql' },
});
}
return () => {
isInit.current = true;
};
}, [getFields, optionsSelected.query]);
useEffect(() => {
if (
formEqlQueryBar != null &&
prevEqlQuery.current !== formEqlQueryBar.query.query &&
isQueryBarValid &&
!isQueryBarValidating
) {
prevEqlQuery.current = formEqlQueryBar.query.query;
dispatch(
timelineActions.updateEqlOptions({
id: timelineId,
field: 'query',
value: `${formEqlQueryBar.query.query}`,
})
);
setIsQueryBarValid(false);
setIsQueryBarValidating(false);
}
}, [dispatch, formEqlQueryBar, isQueryBarValid, isQueryBarValidating, timelineId]);
const dataView = useMemo(
() => ({
...sourcererDataView,
title: sourcererDataView?.title ?? '',
fields: Object.values(sourcererDataView?.fields || {}),
}),
[sourcererDataView]
);
/* Force casting `sourcererDataView` to `DataViewBase` is required since EqlQueryEdit
accepts DataViewBase but `useSourcererDataView()` returns `DataViewSpec`.
When using `UseField` with `EqlQueryBar` such casting isn't required by TS since
`UseField` component props are types as `Record<string, any>`. */
return (
<Form form={form} data-test-subj="EqlQueryBarTimeline">
<UseField key="Index" path="index" className={hiddenUseFieldClassName} />
<UseField key="EqlOptions" path="eqlOptions" className={hiddenUseFieldClassName} />
<UseField
key="EqlQueryBar"
path="eqlQueryBar"
component={EqlQueryBar}
componentProps={{
optionsData,
optionsSelected,
onOptionsChange,
onValidityChange: setIsQueryBarValid,
onValiditingChange: setIsQueryBarValidating,
idAria: 'timelineEqlQueryBar',
isDisabled: indexPatternsLoading,
isLoading: indexPatternsLoading,
indexPattern,
dataTestSubj: 'timelineEqlQueryBar',
}}
config={{
...schema.eqlQueryBar,
label: i18n.EQL_QUERY_BAR_LABEL,
}}
/>
<EuiOutsideClickDetector onOutsideClick={handleOutsideEqlQueryEditClick}>
<EqlQueryEdit
key="EqlQueryBar"
path="eqlQueryBar"
eqlOptionsPath="eqlOptions"
showEqlSizeOption
dataView={dataView}
loading={indexPatternsLoading}
disabled={indexPatternsLoading}
/>
</EuiOutsideClickDetector>
</Form>
);
});

View file

@ -13,12 +13,7 @@ export const getEqlOptions = () =>
selectTimeline,
(timeline) =>
timeline?.eqlOptions ?? {
eventCategoryField: [{ label: 'event.category' }],
tiebreakerField: [
{
label: '',
},
],
eventCategoryField: 'event.category',
timestampField: [
{
label: '@timestamp',

View file

@ -41,7 +41,7 @@ import { TimelineId } from '../../../common/types/timeline';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
import { activeTimeline } from './active_timeline_context';
import type {
EqlOptionsSelected,
EqlOptions,
TimelineEqlResponse,
} from '../../../common/search_strategy/timeline/events/eql';
import { useTrackHttpRequest } from '../../common/lib/apm/use_track_http_request';
@ -84,7 +84,7 @@ type TimelineResponse<T extends KueryFilterQueryKind> = T extends 'kuery'
export interface UseTimelineEventsProps {
dataViewId: string | null;
endDate?: string;
eqlOptions?: EqlOptionsSelected;
eqlOptions?: EqlOptions;
fields: string[];
filterQuery?: ESQuery | string;
id: string;
@ -112,7 +112,7 @@ export const initSortDefault: TimelineRequestSortField[] = [
},
];
const deStructureEqlOptions = (eqlOptions?: EqlOptionsSelected) => ({
const deStructureEqlOptions = (eqlOptions?: EqlOptions) => ({
...(!isEmpty(eqlOptions?.eventCategoryField)
? {
eventCategoryField: eqlOptions?.eventCategoryField,

View file

@ -188,7 +188,7 @@ export const toggleModalSaveTimeline = actionCreator<{
export const updateEqlOptions = actionCreator<{
id: string;
field: FieldsEqlOptions;
value: string | undefined;
value: string | number | undefined;
}>('UPDATE_EQL_OPTIONS_TIMELINE');
export const setEventsLoading = actionCreator<{

View file

@ -32,7 +32,6 @@ export const timelineDefaults: SubsetTimelineModel &
description: '',
eqlOptions: {
eventCategoryField: 'event.category',
tiebreakerField: '',
timestampField: '@timestamp',
query: '',
size: 100,

View file

@ -8,10 +8,7 @@
import type { Filter } from '@kbn/es-query';
import type { SavedSearch } from '@kbn/saved-search-plugin/common';
import type { SessionViewConfig } from '../../../common/types';
import type {
EqlOptionsSelected,
TimelineNonEcsData,
} from '../../../common/search_strategy/timeline';
import type { EqlOptions, TimelineNonEcsData } from '../../../common/search_strategy/timeline';
import type {
TimelineTabs,
ScrollToTopEvent,
@ -42,7 +39,7 @@ export interface TimelineModel {
createdBy?: string;
/** A summary of the events and notes in this timeline */
description: string;
eqlOptions: EqlOptionsSelected;
eqlOptions: EqlOptions;
/** Type of event you want to see in this timeline */
eventType?: TimelineEventsType;
/** A map of events in this timeline to the chronologically ordered notes (in this timeline) associated with the event */

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash';
import { get, has } from 'lodash';
import type {
RuleSchedule,
DataSourceIndexPatterns,
@ -48,9 +48,13 @@ export const mapDiffableRuleFieldValueToRuleSchemaFormat = (
return transformedValue.value;
}
if (!SUBFIELD_MAPPING[fieldName] && !has(diffableField, diffableRuleSubfieldName)) {
return diffableField;
}
// From the ThreeWayDiff, get the specific field that maps to the diffable rule field
// Otherwise, the diffableField itself already matches the rule field, so retrieve that value.
const mappedField = get(diffableField, diffableRuleSubfieldName, diffableField);
const mappedField = get(diffableField, diffableRuleSubfieldName);
return mappedField;
};
@ -81,24 +85,6 @@ export function mapRuleFieldToDiffableRuleField({
ruleType,
fieldName,
}: MapRuleFieldToDiffableRuleFieldParams): keyof AllFieldsDiff {
const diffableRuleFieldMap: Record<string, keyof AllFieldsDiff> = {
building_block_type: 'building_block',
saved_id: 'kql_query',
threat_query: 'threat_query',
threat_language: 'threat_query',
threat_filters: 'threat_query',
index: 'data_source',
data_view_id: 'data_source',
rule_name_override: 'rule_name_override',
interval: 'rule_schedule',
from: 'rule_schedule',
to: 'rule_schedule',
timeline_id: 'timeline_template',
timeline_title: 'timeline_template',
timestamp_override: 'timestamp_override',
timestamp_override_fallback_disabled: 'timestamp_override',
};
// Handle query, filters and language fields based on rule type
if (fieldName === 'query' || fieldName === 'language' || fieldName === 'filters') {
switch (ruleType) {
@ -114,9 +100,48 @@ export function mapRuleFieldToDiffableRuleField({
}
}
const diffableRuleFieldMap: Record<string, keyof AllFieldsDiff> = {
building_block_type: 'building_block',
saved_id: 'kql_query',
event_category_override: 'eql_query',
tiebreaker_field: 'eql_query',
timestamp_field: 'eql_query',
threat_query: 'threat_query',
threat_language: 'threat_query',
threat_filters: 'threat_query',
index: 'data_source',
data_view_id: 'data_source',
rule_name_override: 'rule_name_override',
interval: 'rule_schedule',
from: 'rule_schedule',
to: 'rule_schedule',
timeline_id: 'timeline_template',
timeline_title: 'timeline_template',
timestamp_override: 'timestamp_override',
timestamp_override_fallback_disabled: 'timestamp_override',
};
return diffableRuleFieldMap[fieldName] || fieldName;
}
const SUBFIELD_MAPPING: Record<string, string> = {
index: 'index_patterns',
data_view_id: 'data_view_id',
saved_id: 'saved_query_id',
event_category_override: 'event_category_override',
tiebreaker_field: 'tiebreaker_field',
timestamp_field: 'timestamp_field',
building_block_type: 'type',
rule_name_override: 'field_name',
timestamp_override: 'field_name',
timestamp_override_fallback_disabled: 'fallback_disabled',
timeline_id: 'timeline_id',
timeline_title: 'timeline_title',
interval: 'interval',
from: 'lookback',
to: 'lookback',
};
/**
* Maps a PrebuiltRuleAsset schema field name to its corresponding property
* name within a DiffableRule group.
@ -134,22 +159,7 @@ export function mapRuleFieldToDiffableRuleField({
*
*/
export function mapRuleFieldToDiffableRuleSubfield(fieldName: string): string {
const fieldMapping: Record<string, string> = {
index: 'index_patterns',
data_view_id: 'data_view_id',
saved_id: 'saved_query_id',
building_block_type: 'type',
rule_name_override: 'field_name',
timestamp_override: 'field_name',
timestamp_override_fallback_disabled: 'fallback_disabled',
timeline_id: 'timeline_id',
timeline_title: 'timeline_title',
interval: 'interval',
from: 'lookback',
to: 'lookback',
};
return fieldMapping[fieldName] || fieldName;
return SUBFIELD_MAPPING[fieldName] || fieldName;
}
type TransformValuesReturnType =

View file

@ -239,9 +239,6 @@ const eqlFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableEqlFields> = {
type: ruleTypeDiffAlgorithm,
eql_query: eqlQueryDiffAlgorithm,
data_source: dataSourceDiffAlgorithm,
event_category_override: singleLineStringDiffAlgorithm,
timestamp_field: singleLineStringDiffAlgorithm,
tiebreaker_field: singleLineStringDiffAlgorithm,
alert_suppression: simpleDiffAlgorithm,
};

View file

@ -42,8 +42,8 @@ export type {
BeatFields,
BrowserFields,
CursorType,
EqlOptionsData,
EqlOptionsSelected,
EqlFieldsComboBoxOptions,
EqlOptions,
FieldsEqlOptions,
FieldInfo,
IndexField,

View file

@ -22,13 +22,13 @@ export interface TimelineEqlResponse extends EqlSearchStrategyResponse<EqlSearch
inspect: Maybe<Inspect>;
}
export interface EqlOptionsData {
export interface EqlFieldsComboBoxOptions {
keywordFields: EuiComboBoxOptionOption[];
dateFields: EuiComboBoxOptionOption[];
nonDateFields: EuiComboBoxOptionOption[];
}
export interface EqlOptionsSelected {
export interface EqlOptions {
eventCategoryField?: string;
tiebreakerField?: string;
timestampField?: string;
@ -36,4 +36,4 @@ export interface EqlOptionsSelected {
size?: number;
}
export type FieldsEqlOptions = keyof EqlOptionsSelected;
export type FieldsEqlOptions = keyof EqlOptions;

View file

@ -38124,7 +38124,6 @@
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoAriaLabel": "Ouvrir une fenêtre contextuelle d'aide",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipContent": "Consultez {createEsqlRuleTypeLink} pour commencer à utiliser les règles ES|QL.",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipLink": "documentation",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError": "Une requête ES|QL est requise.",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryLabel": "Requête ES|QL",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldAnomalyThresholdLabel": "Seuil de score d'anomalie",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldMachineLearningJobIdLabel": "Tâche de Machine Learning",

View file

@ -38091,7 +38091,6 @@
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoAriaLabel": "ヘルプポップオーバーを開く",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipContent": "ES|QL ルールの使用を開始するには、{createEsqlRuleTypeLink}を確認してください。",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipLink": "ドキュメンテーション",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError": "ES|QLクエリは必須です。",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryLabel": "ES|QLクエリ",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldAnomalyThresholdLabel": "異常スコアしきい値",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldMachineLearningJobIdLabel": "機械学習ジョブ",

View file

@ -38158,7 +38158,6 @@
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoAriaLabel": "打开帮助弹出框",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipContent": "请访问我们的{createEsqlRuleTypeLink}以开始使用 ES|QL 规则。",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipLink": "文档",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError": "ES|QL 查询必填。",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryLabel": "ES|QL 查询",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldAnomalyThresholdLabel": "异常分数阈值",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldMachineLearningJobIdLabel": "Machine Learning 作业",