Skip to content

Commit

Permalink
[Security Solution] Add ES|QL Query editable component (#199887)
Browse files Browse the repository at this point in the history
**Partially addresses:** #171520

## Summary

This PR adds is built on top of #193828 and #196948 and adds an ES|QL Query editable component for Three Way Diff tab's final edit side of the upgrade prebuilt rule workflow.

## Details

This PR extracts ES|QL Query edit component from Define rule form step and makes it reusable. The following changes were made

- ES|QL validator was refactored and covered by unit tests
- Query persistence was addressed and covered by tests (previous functionality didn't work out of the box and didn't have tests)

## How to test

The simplest way to test is via patching installed prebuilt rules (a.k.a. downgrading a prebuilt rule) via Rule Patch API. Please follow steps below

- Ensure the `prebuiltRulesCustomizationEnabled` feature flag is enabled
- Run Kibana locally
- Install an ES|QL prebuilt rule, e.g. `AWS Bedrock Guardrails Detected Multiple Violations by a Single User Over a Session` with rule_id `0cd2f3e6-41da-40e6-b28b-466f688f00a6`
- Patch the installed rule by running a query below

```bash
curl -X PATCH --user elastic:changeme  -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 2023-10-31" -d '{"rule_id":"0cd2f3e6-41da-40e6-b28b-466f688f00a6","version":1,"query":"from logs-*","language":"esql"}' http://localhost:5601/kbn/api/detection_engine/rules
```

- Open `Detection Rules (SIEM)` Page -> `Rule Updates` -> click on `AWS Bedrock Guardrails Detected Multiple Violations by a Single User Over a Session` rule -> expand `EQL Query` to see EQL Query -> press `Edit` button

## Screenshots

<img width="2550" alt="image" src="https://github.com/user-attachments/assets/f65d04d5-9fd9-4d3f-8741-eba04a3be8a6">

<img width="2552" alt="image" src="https://github.com/user-attachments/assets/dd0a2613-5262-44b2-bbeb-d0ed34d57d9c">
  • Loading branch information
maximpn authored Nov 28, 2024
1 parent 979f3e8 commit e55232f
Show file tree
Hide file tree
Showing 59 changed files with 1,375 additions and 905 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,17 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { ESQLAst, getAstAndSyntaxErrors } from '@kbn/esql-ast';
import { type ESQLAstQueryExpression, parse } from '@kbn/esql-ast';

export const isAggregatingQuery = (ast: ESQLAst): boolean => {
return ast.some((astItem) => astItem.type === 'command' && astItem.name === 'stats');
};
export const isAggregatingQuery = (astExpression: ESQLAstQueryExpression): boolean =>
astExpression.commands.some((command) => command.name === 'stats');

/**
* compute if esqlQuery is aggregating/grouping, i.e. using STATS...BY command
* @param esqlQuery
* @returns boolean
*/
export const computeIsESQLQueryAggregating = (esqlQuery: string): boolean => {
const { ast } = getAstAndSyntaxErrors(esqlQuery);
return isAggregatingQuery(ast);
const { root } = parse(esqlQuery);
return isAggregatingQuery(root);
};

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import type { FieldHook } from '../../../../shared_imports';
import { FilterBar } from '../../../../common/components/filter_bar';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { EqlOptions } from '../../../../../common/search_strategy';
import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar_field';
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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ 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 type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar_field';
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
import { isEmpty } from 'lodash';
import type { FormData, ValidationError, ValidationFunc } from '../../../../shared_imports';
import { KibanaServices } from '../../../../common/lib/kibana';
import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar_field';
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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,19 @@
* 2.0.
*/

import React from 'react';
import React, { memo } from 'react';
import { EuiPopover, EuiText, EuiButtonIcon, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from './translations';

import { useBoolState } from '../../../../common/hooks/use_bool_state';
import { useBoolean } from '@kbn/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import * as i18n from './translations';

/**
* Icon and popover that gives hint to users how to get started with ES|QL rules
*/
const EsqlInfoIconComponent = () => {
export const EsqlInfoIcon = memo(function EsqlInfoIcon(): JSX.Element {
const { docLinks } = useKibana().services;

const [isPopoverOpen, , closePopover, togglePopover] = useBoolState();
const [isPopoverOpen, { off: closePopover, on: togglePopover }] = useBoolean(false);

const button = (
<EuiButtonIcon iconType="iInCircle" onClick={togglePopover} aria-label={i18n.ARIA_LABEL} />
Expand All @@ -29,13 +27,13 @@ const EsqlInfoIconComponent = () => {
<EuiPopover button={button} isOpen={isPopoverOpen} closePopover={closePopover}>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipContent"
id="xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipContent"
defaultMessage="Check out our {createEsqlRuleTypeLink} to get started using ES|QL rules."
values={{
createEsqlRuleTypeLink: (
<EuiLink href={docLinks.links.securitySolution.createEsqlRuleType} target="_blank">
<FormattedMessage
id="xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipLink"
id="xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipLink"
defaultMessage="documentation"
/>
</EuiLink>
Expand All @@ -45,8 +43,4 @@ const EsqlInfoIconComponent = () => {
</EuiText>
</EuiPopover>
);
};

export const EsqlInfoIcon = React.memo(EsqlInfoIconComponent);

EsqlInfoIcon.displayName = 'EsqlInfoIcon';
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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, { memo, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { DataViewBase } from '@kbn/es-query';
import { debounceAsync } from '@kbn/securitysolution-utils';
import type { FieldConfig } from '../../../../shared_imports';
import { UseField } from '../../../../shared_imports';
import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar_field';
import { QueryBarField } from '../../../rule_creation_ui/components/query_bar_field';
import { esqlQueryRequiredValidator } from './validators/esql_query_required_validator';
import { esqlQueryValidatorFactory } from './validators/esql_query_validator_factory';
import { EsqlInfoIcon } from './esql_info_icon';
import * as i18n from './translations';

interface EsqlQueryEditProps {
path: string;
fieldsToValidateOnChange?: string | string[];
dataView: DataViewBase;
required?: boolean;
loading?: boolean;
disabled?: boolean;
skipIdColumnCheck?: boolean;
onValidityChange?: (arg: boolean) => void;
}

export const EsqlQueryEdit = memo(function EsqlQueryEdit({
path,
fieldsToValidateOnChange,
dataView,
required = false,
loading = false,
disabled = false,
skipIdColumnCheck,
onValidityChange,
}: EsqlQueryEditProps): JSX.Element {
const queryClient = useQueryClient();
const componentProps = useMemo(
() => ({
isDisabled: disabled,
isLoading: loading,
indexPattern: dataView,
idAria: 'ruleEsqlQueryBar',
dataTestSubj: 'ruleEsqlQueryBar',
onValidityChange,
}),
[dataView, loading, disabled, onValidityChange]
);
const fieldConfig: FieldConfig<FieldValueQueryBar> = useMemo(
() => ({
label: i18n.ESQL_QUERY,
labelAppend: <EsqlInfoIcon />,
fieldsToValidateOnChange: fieldsToValidateOnChange
? [path, fieldsToValidateOnChange].flat()
: undefined,
validations: [
...(required
? [
{
validator: esqlQueryRequiredValidator,
},
]
: []),
{
validator: debounceAsync(
esqlQueryValidatorFactory({ queryClient, skipIdColumnCheck }),
300
),
},
],
}),
[required, path, fieldsToValidateOnChange, queryClient, skipIdColumnCheck]
);

return (
<UseField
path={path}
component={QueryBarField}
componentProps={componentProps}
config={fieldConfig}
/>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* 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 './esql_query_edit';
export * from './validators/error_codes';
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@

import { i18n } from '@kbn/i18n';

export const ESQL_QUERY = i18n.translate(
'xpack.securitySolution.ruleManagement.fields.esqlQuery.label',
{
defaultMessage: 'ES|QL query',
}
);

export const ARIA_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoAriaLabel',
'xpack.securitySolution.ruleManagement.fields.esqlQuery.ariaLabel',
{
defaultMessage: `Open help popover`,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export enum ESQL_ERROR_CODES {
INVALID_ESQL = 'ERR_INVALID_ESQL',
INVALID_SYNTAX = 'ERR_INVALID_SYNTAX',
ERR_MISSING_ID_FIELD_FROM_RESULT = 'ERR_MISSING_ID_FIELD_FROM_RESULT',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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 { fieldValidators, type FormData, type ValidationFunc } from '../../../../../shared_imports';
import type { FieldValueQueryBar } from '../../../../rule_creation_ui/components/query_bar_field';
import * as i18n from './translations';

export const esqlQueryRequiredValidator: ValidationFunc<FormData, string, FieldValueQueryBar> = (
data
) => {
const { value } = data;
const esqlQuery = value.query.query as string;

return fieldValidators.emptyField(i18n.ESQL_QUERY_VALIDATION_REQUIRED)({
...data,
value: esqlQuery,
});
};
Loading

0 comments on commit e55232f

Please sign in to comment.