Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution] Add EQL query editable component with EQL options fields #199115

Merged
merged 28 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
788b719
add eql query editable component
maximpn Nov 6, 2024
f1f4e54
wrap EqlQueryBar into EqlQueryEdit component with reasonable defaults
maximpn Nov 6, 2024
ad8acbd
add eql options fields
maximpn Nov 6, 2024
960e68b
remove unused translation keys
maximpn Nov 6, 2024
f20742a
properly run first EQL Query validation in Three Way Diff
maximpn Nov 6, 2024
57075b0
shorten long paths
maximpn Nov 6, 2024
a0df404
remove empty tiebreakerField's default value
maximpn Nov 6, 2024
fe00b57
remove unused EQL validation
maximpn Nov 6, 2024
878f167
expose showEqlSizeOption property
maximpn Nov 6, 2024
aa3360a
expose showFilterBar prop
maximpn Nov 6, 2024
759e021
fix unit tests
maximpn Nov 7, 2024
ebb386c
remove commented code
maximpn Nov 11, 2024
d2adba9
use comma delimiter while joining index patterns
maximpn Nov 11, 2024
635fcc9
fix a typo
maximpn Nov 12, 2024
c54ac35
add assertUnreachable for rules having all editable fields implemented
maximpn Nov 12, 2024
ca8b4af
fix post-rebase issues
maximpn Nov 12, 2024
94a2be9
add fieldsToValidateOnChange to EqlQueryEdit
maximpn Nov 13, 2024
35bf0d3
use form field for EQL Options
maximpn Nov 15, 2024
dd8dd2b
prevent dispatching unchanged EQL Options
maximpn Nov 18, 2024
2499e50
skip EQL validation for prebuilt rules customization workflow
maximpn Nov 21, 2024
557782a
combine EQL query and EQL options
maximpn Nov 21, 2024
58b2f35
show EQL options in rule details page
maximpn Nov 21, 2024
3f7c96f
move eql edit component in a shared folder
maximpn Nov 21, 2024
0b8fd8f
make eqlOptionsPath required
maximpn Nov 21, 2024
1eedbd2
move EQL options combo box options calc inside EQL Query bar
maximpn Nov 21, 2024
45bfabc
display EQL options diff
maximpn Nov 21, 2024
e9ee5c7
move debounceAsync to a package
maximpn Nov 21, 2024
9f5d1f8
map properly EQL options to eql_query in upgrade perform API endpoint
maximpn Nov 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kbn-securitysolution-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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');

Expand All @@ -39,6 +39,8 @@ describe('debounceAsync', () => {
});

it('debounces the function', async () => {
const fn = jest.fn().mockResolvedValueOnce('first');

const debounced = debounceAsync(fn, 200);

debounced();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { z } from '@kbn/zod';
import {
AlertSuppression,
AnomalyThreshold,
EventCategoryOverride,
HistoryWindowStart,
InvestigationFields,
InvestigationGuide,
Expand Down Expand Up @@ -37,8 +36,6 @@ import {
ThreatMapping,
Threshold,
ThresholdAlertSuppression,
TiebreakerField,
TimestampField,
} from '../../../../model/rule_schema';

import {
Expand Down Expand Up @@ -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(),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
import type {
EqlQueryLanguage,
EsqlQueryLanguage,
EventCategoryOverride,
KqlQueryLanguage,
RuleFilterArray,
RuleQuery,
TiebreakerField,
TimestampField,
} from '../../../api/detection_engine/model/rule_schema';
import type {
InlineKqlQuery,
Expand Down Expand Up @@ -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,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

export type {
TimelineEqlResponse,
EqlOptionsData,
EqlOptionsSelected,
EqlFieldsComboBoxOptions,
EqlOptions,
FieldsEqlOptions,
} from '@kbn/timelines-plugin/common';
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const triggerValidateEql = () => {
query: 'any where true',
signal,
runtimeMappings: undefined,
options: undefined,
eqlOptions: undefined,
});
};

Expand Down
32 changes: 20 additions & 12 deletions x-pack/plugins/security_solution/public/common/hooks/eql/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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(
Expand All @@ -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] },
},
Expand All @@ -79,26 +82,31 @@ 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 {
valid: false,
error: { code: EQL_ERROR_CODES.MISSING_DATA_SOURCE, messages: [error.message] },
};
}

return {
valid: false,
error: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,6 @@ export const mockGlobalState: State = {
description: '',
eqlOptions: {
eventCategoryField: 'event.category',
tiebreakerField: '',
timestampField: '@timestamp',
},
eventIdToNoteIds: { '1': ['1'] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2051,7 +2051,6 @@ export const defaultTimelineProps: CreateTimelineProps = {
eventCategoryField: 'event.category',
query: '',
size: 100,
tiebreakerField: '',
timestampField: '@timestamp',
},
eventIdToNoteIds: {},
Expand Down
Loading