Skip to content

Commit

Permalink
[Detections] EQL query validation: Separate syntax errors into a own …
Browse files Browse the repository at this point in the history
…error type (elastic#10181)
  • Loading branch information
e40pud committed Aug 8, 2024
1 parent ef8caa8 commit b11642e
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,27 @@ export interface ErrorResponse {
const isValidationErrorType = (type: unknown): boolean =>
type === PARSING_ERROR_TYPE || type === VERIFICATION_ERROR_TYPE || type === MAPPING_ERROR_TYPE;

const isParsingErrorType = (type: unknown): boolean => type === PARSING_ERROR_TYPE;

const isVerificationErrorType = (type: unknown): boolean => type === VERIFICATION_ERROR_TYPE;

const isMappingErrorType = (type: unknown): boolean => type === MAPPING_ERROR_TYPE;

export const isErrorResponse = (response: unknown): response is ErrorResponse =>
has(response, 'error.type');

export const isValidationErrorResponse = (response: unknown): response is ErrorResponse =>
isErrorResponse(response) && isValidationErrorType(get(response, 'error.type'));

export const isParsingErrorResponse = (response: unknown): response is ErrorResponse =>
isErrorResponse(response) && isParsingErrorType(get(response, 'error.type'));

export const isVerificationErrorResponse = (response: unknown): response is ErrorResponse =>
isErrorResponse(response) && isVerificationErrorType(get(response, 'error.type'));

export const isMappingErrorResponse = (response: unknown): response is ErrorResponse =>
isErrorResponse(response) && isMappingErrorType(get(response, 'error.type'));

export const getValidationErrors = (response: ErrorResponse): string[] =>
response.error.root_cause
.filter((cause) => isValidationErrorType(cause.type))
Expand Down
87 changes: 62 additions & 25 deletions x-pack/plugins/security_solution/public/common/hooks/eql/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ import type { EqlOptionsSelected } from '../../../../common/search_strategy';
import {
getValidationErrors,
isErrorResponse,
isValidationErrorResponse,
isMappingErrorResponse,
isParsingErrorResponse,
isVerificationErrorResponse,
} from '../../../../common/search_strategy/eql';

export enum EQL_ERROR_CODES {
FAILED_REQUEST = 'ERR_FAILED_REQUEST',
INVALID_EQL = 'ERR_INVALID_EQL',
INVALID_SYNTAX = 'ERR_INVALID_SYNTAX',
}

interface Params {
dataViewTitle: string;
query: string;
Expand All @@ -34,31 +42,60 @@ export const validateEql = async ({
signal,
runtimeMappings,
options,
}: Params): Promise<{ valid: boolean; errors: string[] }> => {
const { rawResponse: response } = await firstValueFrom(
data.search.search<EqlSearchStrategyRequest, EqlSearchStrategyResponse>(
{
params: {
index: dataViewTitle,
body: { query, runtime_mappings: runtimeMappings, size: 0 },
timestamp_field: options?.timestampField,
tiebreaker_field: options?.tiebreakerField || undefined,
event_category_field: options?.eventCategoryField,
}: Params): Promise<{
valid: boolean;
error?: { code: EQL_ERROR_CODES; messages?: string[]; error?: Error };
}> => {
try {
const { rawResponse: response } = await firstValueFrom(
data.search.search<EqlSearchStrategyRequest, EqlSearchStrategyResponse>(
{
params: {
index: dataViewTitle,
body: { query, runtime_mappings: runtimeMappings, size: 0 },
timestamp_field: options?.timestampField,
tiebreaker_field: options?.tiebreakerField || undefined,
event_category_field: options?.eventCategoryField,
},
options: { ignore: [400] },
},
options: { ignore: [400] },
{
strategy: EQL_SEARCH_STRATEGY,
abortSignal: signal,
}
)
);
if (isParsingErrorResponse(response)) {
return {
valid: false,
error: { code: EQL_ERROR_CODES.INVALID_SYNTAX, messages: getValidationErrors(response) },
};
} else if (isVerificationErrorResponse(response) || isMappingErrorResponse(response)) {
return {
valid: false,
error: { code: EQL_ERROR_CODES.INVALID_EQL, messages: getValidationErrors(response) },
};
} else if (isErrorResponse(response)) {
return {
valid: false,
error: { code: EQL_ERROR_CODES.FAILED_REQUEST, error: new Error(JSON.stringify(response)) },
};
} else {
return { valid: true };
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('index_not_found_exception')) {
return {
valid: false,
error: { code: EQL_ERROR_CODES.INVALID_EQL, messages: [error.message] },
};
}
return {
valid: false,
error: {
code: EQL_ERROR_CODES.FAILED_REQUEST,
error,
},
{
strategy: EQL_SEARCH_STRATEGY,
abortSignal: signal,
}
)
);

if (isValidationErrorResponse(response)) {
return { valid: false, errors: getValidationErrors(response) };
} else if (isErrorResponse(response)) {
throw new Error(JSON.stringify(response));
} else {
return { valid: true, errors: [] };
};
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,8 @@ 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 { validateEql } 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';

export enum ERROR_CODES {
FAILED_REQUEST = 'ERR_FAILED_REQUEST',
INVALID_EQL = 'ERR_INVALID_EQL',
}

/**
* Unlike lodash's debounce, which resolves intermediate calls with the most
Expand Down Expand Up @@ -54,7 +48,7 @@ export const debounceAsync = <Args extends unknown[], Result extends Promise<unk

export const eqlValidator = async (
...args: Parameters<ValidationFunc>
): Promise<ValidationError<ERROR_CODES> | void | undefined> => {
): Promise<ValidationError<EQL_ERROR_CODES> | void | undefined> => {
const [{ value, formData }] = args;
const { query: queryValue } = value as FieldValueQueryBar;
const query = queryValue.query as string;
Expand All @@ -66,43 +60,36 @@ export const eqlValidator = async (
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,
});
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();
}

if (response?.valid === false) {
return {
code: ERROR_CODES.INVALID_EQL,
message: '',
messages: response.errors,
};
}
} catch (error) {
const signal = new AbortController().signal;
const response = await validateEql({
data,
query,
signal,
dataViewTitle,
runtimeMappings,
options: eqlOptions,
});

if (response?.valid === false) {
return {
code: ERROR_CODES.FAILED_REQUEST,
message: i18n.EQL_VALIDATION_REQUEST_ERROR,
error,
code: response.error?.code,
message: '',
messages: response.error?.messages,
error: response.error?.error,
};
}
};
Expand All @@ -117,10 +104,10 @@ export const getValidationResults = <T = unknown>(
const [error] = field.errors;
const message = error.message;

if (error.code === ERROR_CODES.INVALID_EQL) {
return { isValid, message, messages: error.messages };
} else {
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: '' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
EQL_OPTIONS_TIMESTAMP_INPUT,
EQL_QUERY_INPUT,
EQL_QUERY_VALIDATION_ERROR,
EQL_QUERY_VALIDATION_ERROR_CONTENT,
RULES_CREATION_FORM,
} from '../../../../screens/create_new_rule';

Expand Down Expand Up @@ -222,4 +223,67 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => {
cy.get(EQL_QUERY_VALIDATION_ERROR).should('not.exist');
});
});

describe('EQL query validation', () => {
it('validates missing data source', () => {
const indexPattern = 'non-existing-index';

login();
visit(CREATE_RULE_URL);
selectEqlRuleType();
getIndexPatternClearButton().click();
getRuleIndexInput().type(`${indexPattern}{enter}`);

cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('exist');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('be.visible');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).type('any where true');

cy.get(EQL_QUERY_VALIDATION_ERROR).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR).should('have.text', '1');
cy.get(EQL_QUERY_VALIDATION_ERROR).click();
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should(
'have.text',
`EQL Validation Errorsindex_not_found_exception\n\tCaused by:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [${indexPattern}]\n\tRoot causes:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [${indexPattern}]`
);
});

it('validates missing data fields', () => {
login();
visit(CREATE_RULE_URL);
selectEqlRuleType();

cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('exist');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('be.visible');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).type('any where field1');

cy.get(EQL_QUERY_VALIDATION_ERROR).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR).should('have.text', '1');
cy.get(EQL_QUERY_VALIDATION_ERROR).click();
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should(
'have.text',
'EQL Validation ErrorsFound 1 problem\nline 1:11: Unknown column [field1]'
);
});

it('validates syntax errors', () => {
login();
visit(CREATE_RULE_URL);
selectEqlRuleType();

cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('exist');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('be.visible');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).type('test any where true');

cy.get(EQL_QUERY_VALIDATION_ERROR).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR).should('have.text', '1');
cy.get(EQL_QUERY_VALIDATION_ERROR).click();
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should(
'have.text',
`EQL Validation Errorsline 1:6: extraneous input 'any' expecting 'where'`
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export const EQL_QUERY_VALIDATION_LABEL = '.euiFormLabel-isInvalid';

export const EQL_QUERY_VALIDATION_ERROR = '[data-test-subj="eql-validation-errors-popover-button"]';

export const EQL_QUERY_VALIDATION_ERROR_CONTENT =
'[data-test-subj="eql-validation-errors-popover-content"]';

export const EQL_OPTIONS_POPOVER_TRIGGER = '[data-test-subj="eql-settings-trigger"]';

export const EQL_OPTIONS_TIMESTAMP_INPUT =
Expand Down

0 comments on commit b11642e

Please sign in to comment.