Skip to content

Commit

Permalink
[Security Solution][Detection Engine] marks ES|QL rule type errors as…
Browse files Browse the repository at this point in the history
… user errors (elastic#211064)

## Summary

- addresses elastic#211003
- marks syntax, data verification(missing indices or wrong type of
index), license errors as user errors to avoid triggering response-ops
Serveless SLO

### Testing

create ES|QL rule with invalid query syntax through API call: `from
YOUR_INDEX metadata _id |`
run rule, observe error
use any debugging method to check that in
`x-pack/platform/plugins/shared/alerting/server/monitoring/rule_result_service.ts`
alerting method `addLastRunError` reports userError

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
vitaliidm and kibanamachine authored Feb 24, 2025
1 parent ee7f1b5 commit 6345c2b
Show file tree
Hide file tree
Showing 4 changed files with 294 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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 dateMath from '@kbn/datemath';
import { KbnServerError } from '@kbn/kibana-utils-plugin/server';

import type { RuleExecutorServicesMock } from '@kbn/alerting-plugin/server/mocks';
import { alertsMock } from '@kbn/alerting-plugin/server/mocks';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import type { ExperimentalFeatures } from '../../../../../common';
import { getIndexVersion } from '../../routes/index/get_index_version';
import { SIGNALS_TEMPLATE_VERSION } from '../../routes/index/get_signals_template';
import type { EsqlRuleParams } from '../../rule_schema';
import { getCompleteRuleMock, getEsqlRuleParams } from '../../rule_schema/mocks';
import { ruleExecutionLogMock } from '../../rule_monitoring/mocks';
import { esqlExecutor } from './esql';
import { getDataTierFilter } from '../utils/get_data_tier_filter';

jest.mock('../../routes/index/get_index_version');
jest.mock('../utils/get_data_tier_filter', () => ({ getDataTierFilter: jest.fn() }));

const getDataTierFilterMock = getDataTierFilter as jest.Mock;

describe('esqlExecutor', () => {
const version = '9.1.0';
const ruleExecutionLogger = ruleExecutionLogMock.forExecutors.create();
let alertServices: RuleExecutorServicesMock;
(getIndexVersion as jest.Mock).mockReturnValue(SIGNALS_TEMPLATE_VERSION);
const params = getEsqlRuleParams();
const esqlCompleteRule = getCompleteRuleMock<EsqlRuleParams>(params);
const tuple = {
from: dateMath.parse(params.from)!,
to: dateMath.parse(params.to)!,
maxSignals: params.maxSignals,
};
const mockExperimentalFeatures = {} as ExperimentalFeatures;
const mockScheduleNotificationResponseActionsService = jest.fn();
const SPACE_ID = 'space';
const PUBLIC_BASE_URL = 'http://testkibanabaseurl.com';

let mockedArguments: Parameters<typeof esqlExecutor>[0];

beforeEach(() => {
jest.clearAllMocks();
alertServices = alertsMock.createRuleExecutorServices();
getDataTierFilterMock.mockResolvedValue([]);

mockedArguments = {
runOpts: {
completeRule: esqlCompleteRule,
tuple,
ruleExecutionLogger,
bulkCreate: jest.fn(),
mergeStrategy: 'allFields',
primaryTimestamp: '@timestamp',
alertWithSuppression: jest.fn(),
unprocessedExceptions: [getExceptionListItemSchemaMock()],
publicBaseUrl: PUBLIC_BASE_URL,
},
services: alertServices,
version,
licensing: {},
spaceId: SPACE_ID,
experimentalFeatures: mockExperimentalFeatures,
scheduleNotificationResponseActionsService: mockScheduleNotificationResponseActionsService,
} as unknown as Parameters<typeof esqlExecutor>[0];
});

describe('errors', () => {
it('should return result with user error equal true when request fails with data verification exception', async () => {
alertServices.scopedClusterClient.asCurrentUser.transport.request.mockRejectedValue(
new KbnServerError(
'verification_exception: Found 1 problem\nline 1:45: invalid [test_not_lookup] resolution in lookup mode to an index in [standard] mode',
400,
{
error: {
root_cause: [
{
type: 'verification_exception',
reason:
'Found 1 problem\nline 1:45: invalid [test_not_lookup] resolution in lookup mode to an index in [standard] mode',
},
],
type: 'verification_exception',
reason:
'Found 1 problem\nline 1:45: invalid [test_not_lookup] resolution in lookup mode to an index in [standard] mode',
},
}
)
);

const result = await esqlExecutor(mockedArguments);

expect(result).toHaveProperty('userError', true);
expect(result).toHaveProperty('errors', [
'verification_exception: Found 1 problem\nline 1:45: invalid [test_not_lookup] resolution in lookup mode to an index in [standard] mode',
]);
});

it('should return result without user error when request fails with non-categorized error', async () => {
alertServices.scopedClusterClient.asCurrentUser.transport.request.mockRejectedValue(
new KbnServerError('Unknown Error', 500, {
error: {
root_cause: [
{
type: 'unknown',
reason: 'Unknown Error',
},
],
type: 'unknown',
reason: 'Unknown Error',
},
})
);

const result = await esqlExecutor(mockedArguments);

expect(result).not.toHaveProperty('userError');
expect(result).toHaveProperty('errors', ['Unknown Error']);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { RulePreviewLoggedRequest } from '../../../../../common/api/detecti
import type { CreateRuleOptions, RunOpts, SignalSource } from '../types';
import { logEsqlRequest } from '../utils/logged_requests';
import { getDataTierFilter } from '../utils/get_data_tier_filter';
import { checkErrorDetails } from './utils/check_error_details';
import * as i18n from '../translations';

import {
Expand Down Expand Up @@ -274,6 +275,9 @@ export const esqlExecutor = async ({
size += tuple.maxSignals;
}
} catch (error) {
if (checkErrorDetails(error).isUserError) {
result.userError = true;
}
result.errors.push(error.message);
result.success = false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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 { checkErrorDetails } from './check_error_details';

describe('checkErrorDetails', () => {
describe('syntax errors', () => {
it('should mark as user error from search response body', () => {
const errorResponse = {
errBody: {
error: {
root_cause: [
{
type: 'parsing_exception',
reason:
"line 1:33: mismatched input '|' expecting {'dissect', 'drop', 'enrich', 'eval', 'grok', 'keep', 'limit', 'mv_expand', 'rename', 'sort', 'stats', 'where', 'lookup', DEV_CHANGE_POINT, DEV_INLINESTATS, DEV_LOOKUP, DEV_JOIN_LEFT, DEV_JOIN_RIGHT}",
},
],
type: 'parsing_exception',
reason:
"line 1:33: mismatched input '|' expecting {'dissect', 'drop', 'enrich', 'eval', 'grok', 'keep', 'limit', 'mv_expand', 'rename', 'sort', 'stats', 'where', 'lookup', DEV_CHANGE_POINT, DEV_INLINESTATS, DEV_LOOKUP, DEV_JOIN_LEFT, DEV_JOIN_RIGHT}",
caused_by: {
type: 'input_mismatch_exception',
reason: null,
},
},
},
};

expect(checkErrorDetails(errorResponse)).toHaveProperty('isUserError', true);
});

it('should mark as user error from error message', () => {
const errorMessage = `parsing_exception
Caused by:
input_mismatch_exception: null
Root causes:
parsing_exception: line 1:33: mismatched input '|' expecting {'dissect', 'drop', 'enrich', 'eval', 'grok', 'keep', 'limit', 'mv_expand', 'rename', 'sort', 'stats', 'where', 'lookup', DEV_CHANGE_POINT, DEV_INLINESTATS, DEV_LOOKUP, DEV_JOIN_LEFT, DEV_JOIN_RIGHT}
`;

expect(checkErrorDetails(new Error(errorMessage))).toHaveProperty('isUserError', true);
});
});

describe('data source verification errors', () => {
it('should mark as user error from search response body', () => {
const errorResponse = {
errBody: {
error: {
root_cause: [
{
type: 'verification_exception',
reason:
'Found 1 problem\nline 1:45: invalid [test_not_lookup] resolution in lookup mode to an index in [standard] mode',
},
],
type: 'verification_exception',
reason:
'Found 1 problem\nline 1:45: invalid [test_not_lookup] resolution in lookup mode to an index in [standard] mode',
},
},
};

expect(checkErrorDetails(errorResponse)).toHaveProperty('isUserError', true);
});

it('should mark as user error from error message', () => {
const errorMessage = `verification_exception
Root causes:
verification_exception: Found 1 problem
line 1:45: invalid [test_not_lookup] resolution in lookup mode to an index in [standard] mode
`;

expect(checkErrorDetails(new Error(errorMessage))).toHaveProperty('isUserError', true);
});
});

describe('license errors', () => {
it('should mark as user error from search response body', () => {
const errorResponse = {
errBody: {
error: {
root_cause: [
{
type: 'status_exception',
reason:
'A valid Enterprise license is required to run ES|QL cross-cluster searches. License found: active basic license',
},
],
type: 'status_exception',
reason:
'A valid Enterprise license is required to run ES|QL cross-cluster searches. License found: active basic license',
},
},
};

expect(checkErrorDetails(errorResponse)).toHaveProperty('isUserError', true);
});

it('should mark as user error from error message', () => {
const errorMessage = `status_exception
Root causes:
status_exception: A valid Enterprise license is required to run ES|QL cross-cluster searches. License found: active basic license
`;

expect(checkErrorDetails(new Error(errorMessage))).toHaveProperty('isUserError', true);
});
});

describe('non user errors', () => {
it('should not mark as user error from search response body', () => {
const errorResponse = {
errBody: {
error: {
root_cause: [
{
type: 'unknown_exception',
},
],
type: 'unknown_exception',
},
},
};

expect(checkErrorDetails(errorResponse)).toHaveProperty('isUserError', false);
});

it('should not mark as user error from error message', () => {
const errorMessage = `Fatal server error`;

expect(checkErrorDetails(new Error(errorMessage))).toHaveProperty('isUserError', false);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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 { KbnSearchError } from '@kbn/data-plugin/server/search/report_search_error';

const USER_ERRORS_EXCEPTIONS = ['status_exception', 'verification_exception', 'parsing_exception'];

/**
* if error can be qualified as user error(configurational), returns isUserError: true
* user errors are excluded from SLO dashboards
*/
export const checkErrorDetails = (error: unknown): { isUserError: boolean } => {
const errorType = (error as KbnSearchError)?.errBody?.error?.type;
if (USER_ERRORS_EXCEPTIONS.includes(errorType)) {
return { isUserError: true };
}

const isUserError =
error instanceof Error &&
USER_ERRORS_EXCEPTIONS.some((exception) => error.message.includes(exception));

return { isUserError };
};

0 comments on commit 6345c2b

Please sign in to comment.