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][Exceptions] Implement exceptions for ML rules #84006

Merged
merged 12 commits into from
Dec 2, 2020
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { getQueryFilter, buildExceptionFilter, buildEqlSearchRequest } from './get_query_filter';
import { Filter, EsQueryConfig } from 'src/plugins/data/public';
import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { ExceptionListItemSchema } from '../shared_imports';

describe('get_filter', () => {
describe('getQueryFilter', () => {
Expand Down Expand Up @@ -919,19 +920,27 @@ describe('get_filter', () => {
dateFormatTZ: 'Zulu',
};
test('it should build a filter without chunking exception items', () => {
const exceptionFilter = buildExceptionFilter(
[
{ language: 'kuery', query: 'host.name: linux and some.field: value' },
{ language: 'kuery', query: 'user.name: name' },
const exceptionItem1: ExceptionListItemSchema = {
...getExceptionListItemSchemaMock(),
entries: [
{ field: 'host.name', operator: 'included', type: 'match', value: 'linux' },
{ field: 'some.field', operator: 'included', type: 'match', value: 'value' },
],
{
};
const exceptionItem2: ExceptionListItemSchema = {
...getExceptionListItemSchemaMock(),
entries: [{ field: 'user.name', operator: 'included', type: 'match', value: 'name' }],
};
const exceptionFilter = buildExceptionFilter({
lists: [exceptionItem1, exceptionItem2],
config,
excludeExceptions: true,
chunkSize: 2,
indexPattern: {
fields: [],
title: 'auditbeat-*',
},
config,
true,
2
);
});
expect(exceptionFilter).toEqual({
meta: {
alias: null,
Expand All @@ -949,7 +958,7 @@ describe('get_filter', () => {
minimum_should_match: 1,
should: [
{
match: {
match_phrase: {
'host.name': 'linux',
},
},
Expand All @@ -961,7 +970,7 @@ describe('get_filter', () => {
minimum_should_match: 1,
should: [
{
match: {
match_phrase: {
'some.field': 'value',
},
},
Expand All @@ -976,7 +985,7 @@ describe('get_filter', () => {
minimum_should_match: 1,
should: [
{
match: {
match_phrase: {
'user.name': 'name',
},
},
Expand All @@ -990,20 +999,31 @@ describe('get_filter', () => {
});

test('it should properly chunk exception items', () => {
const exceptionFilter = buildExceptionFilter(
[
{ language: 'kuery', query: 'host.name: linux and some.field: value' },
{ language: 'kuery', query: 'user.name: name' },
{ language: 'kuery', query: 'file.path: /safe/path' },
const exceptionItem1: ExceptionListItemSchema = {
...getExceptionListItemSchemaMock(),
entries: [
{ field: 'host.name', operator: 'included', type: 'match', value: 'linux' },
{ field: 'some.field', operator: 'included', type: 'match', value: 'value' },
],
{
};
const exceptionItem2: ExceptionListItemSchema = {
...getExceptionListItemSchemaMock(),
entries: [{ field: 'user.name', operator: 'included', type: 'match', value: 'name' }],
};
const exceptionItem3: ExceptionListItemSchema = {
...getExceptionListItemSchemaMock(),
entries: [{ field: 'file.path', operator: 'included', type: 'match', value: '/safe/path' }],
};
const exceptionFilter = buildExceptionFilter({
lists: [exceptionItem1, exceptionItem2, exceptionItem3],
config,
excludeExceptions: true,
chunkSize: 2,
indexPattern: {
fields: [],
title: 'auditbeat-*',
},
config,
true,
2
);
});
expect(exceptionFilter).toEqual({
meta: {
alias: null,
Expand All @@ -1024,7 +1044,7 @@ describe('get_filter', () => {
minimum_should_match: 1,
should: [
{
match: {
match_phrase: {
'host.name': 'linux',
},
},
Expand All @@ -1036,7 +1056,7 @@ describe('get_filter', () => {
minimum_should_match: 1,
should: [
{
match: {
match_phrase: {
'some.field': 'value',
},
},
Expand All @@ -1051,7 +1071,7 @@ describe('get_filter', () => {
minimum_should_match: 1,
should: [
{
match: {
match_phrase: {
'user.name': 'name',
},
},
Expand All @@ -1069,7 +1089,7 @@ describe('get_filter', () => {
minimum_should_match: 1,
should: [
{
match: {
match_phrase: {
'file.path': '/safe/path',
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,18 @@ export const getQueryFilter = (
* buildEsQuery, this allows us to offer nested queries
* regardless
*/
const exceptionQueries = buildExceptionListQueries({ language: 'kuery', lists });
if (exceptionQueries.length > 0) {
// Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value),
// allowing us to make 1024-item chunks of exception list items.
// Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a
// very conservative value.
const exceptionFilter = buildExceptionFilter(
exceptionQueries,
indexPattern,
config,
excludeExceptions,
1024
);
// Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value),
// allowing us to make 1024-item chunks of exception list items.
// Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a
// very conservative value.
const exceptionFilter = buildExceptionFilter({
lists,
config,
excludeExceptions,
chunkSize: 1024,
indexPattern,
});
if (exceptionFilter !== undefined) {
enabledFilters.push(exceptionFilter);
}
const initialQuery = { query, language };
Expand Down Expand Up @@ -101,15 +100,17 @@ export const buildEqlSearchRequest = (
ignoreFilterIfFieldNotInIndex: false,
dateFormatTZ: 'Zulu',
};
const exceptionQueries = buildExceptionListQueries({ language: 'kuery', lists: exceptionLists });
let exceptionFilter: Filter | undefined;
if (exceptionQueries.length > 0) {
// Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value),
// allowing us to make 1024-item chunks of exception list items.
// Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a
// very conservative value.
exceptionFilter = buildExceptionFilter(exceptionQueries, indexPattern, config, true, 1024);
}
// Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value),
// allowing us to make 1024-item chunks of exception list items.
// Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a
// very conservative value.
const exceptionFilter = buildExceptionFilter({
lists: exceptionLists,
config,
excludeExceptions: true,
chunkSize: 1024,
indexPattern,
});
const indexString = index.join();
const requestFilter: unknown[] = [
{
Expand Down Expand Up @@ -154,13 +155,23 @@ export const buildEqlSearchRequest = (
}
};

export const buildExceptionFilter = (
exceptionQueries: Query[],
indexPattern: IIndexPattern,
config: EsQueryConfig,
excludeExceptions: boolean,
chunkSize: number
) => {
export const buildExceptionFilter = ({
lists,
config,
excludeExceptions,
chunkSize,
indexPattern,
}: {
lists: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>;
config: EsQueryConfig;
excludeExceptions: boolean;
chunkSize: number;
indexPattern?: IIndexPattern;
}) => {
const exceptionQueries = buildExceptionListQueries({ language: 'kuery', lists });
if (exceptionQueries.length === 0) {
return undefined;
}
const exceptionFilter: Filter = {
meta: {
alias: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,10 @@ export const useFetchIndex = (
);

useEffect(() => {
if (!isEmpty(indexNames) && !isEqual(previousIndexesName.current, indexNames)) {
if (!isEqual(previousIndexesName.current, indexNames)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change might be redundant with mine

indexFieldsSearch(indexNames);
} else {
setLoading(false);
}
}, [indexNames, indexFieldsSearch, previousIndexesName]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { TimelineId } from '../../../../../common/types/timeline';
import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants';
import { Status, Type } from '../../../../../common/detection_engine/schemas/common/schemas';
import { isThresholdRule } from '../../../../../common/detection_engine/utils';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
import { timelineActions } from '../../../../timelines/store/timeline';
import { EventsTd, EventsTdContent } from '../../../../timelines/components/timeline/styles';
import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../../timelines/components/timeline/helpers';
Expand Down Expand Up @@ -75,11 +74,17 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
'',
[ecsRowData]
);
const ruleIndices = useMemo(
(): string[] =>
(ecsRowData.signal?.rule && ecsRowData.signal.rule.index) ?? DEFAULT_INDEX_PATTERN,
[ecsRowData]
);
const ruleIndices = useMemo((): string[] => {
if (
ecsRowData.signal?.rule &&
ecsRowData.signal.rule.index &&
ecsRowData.signal.rule.index.length > 0
) {
return ecsRowData.signal.rule.index;
} else {
return DEFAULT_INDEX_PATTERN;
}
}, [ecsRowData]);

const { addWarning } = useAppToasts();

Expand Down Expand Up @@ -317,7 +322,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
const areExceptionsAllowed = useMemo((): boolean => {
const ruleTypes = getOr([], 'signal.rule.type', ecsRowData);
const [ruleType] = ruleTypes as Type[];
return !isMlRule(ruleType) && !isThresholdRule(ruleType);
return !isThresholdRule(ruleType);
}, [ecsRowData]);

const addExceptionComponent = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui';
import React, { FC, memo, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';

import { isMlRule } from '../../../../../common/machine_learning/helpers';
import { isThresholdRule } from '../../../../../common/detection_engine/utils';
import {
RuleStepProps,
Expand Down Expand Up @@ -76,10 +75,7 @@ const StepAboutRuleComponent: FC<StepAboutRuleProps> = ({
const [severityValue, setSeverityValue] = useState<string>(initialState.severity.value);
const [indexPatternLoading, { indexPatterns }] = useFetchIndex(defineRuleData?.index ?? []);

const canUseExceptions =
defineRuleData?.ruleType &&
!isMlRule(defineRuleData.ruleType) &&
!isThresholdRule(defineRuleData.ruleType);
const canUseExceptions = defineRuleData?.ruleType && !isThresholdRule(defineRuleData.ruleType);

const { form } = useForm<AboutStepRule>({
defaultValue: initialState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ import { DEFAULT_INDEX_PATTERN } from '../../../../../../common/constants';
import { useFullScreen } from '../../../../../common/containers/use_full_screen';
import { Display } from '../../../../../hosts/pages/display';
import { ExceptionListTypeEnum, ExceptionListIdentifiers } from '../../../../../shared_imports';
import { isMlRule } from '../../../../../../common/machine_learning/helpers';
import { isThresholdRule } from '../../../../../../common/detection_engine/utils';
import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_rule_async';
import { showGlobalFilters } from '../../../../../timelines/components/timeline/helpers';
Expand All @@ -104,7 +103,7 @@ enum RuleDetailTabs {
}

const getRuleDetailsTabs = (rule: Rule | null) => {
const canUseExceptions = rule && !isMlRule(rule.type) && !isThresholdRule(rule.type);
const canUseExceptions = rule && !isThresholdRule(rule.type);
return [
{
id: RuleDetailTabs.alerts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ export const sampleDocNoSortIdNoVersion = (someUuid: string = sampleIdGuid): Sig

export const sampleDocWithSortId = (
someUuid: string = sampleIdGuid,
ip?: string,
destIp?: string
ip?: string | string[],
destIp?: string | string[]
): SignalSourceHit => ({
_index: 'myFakeSignalIndex',
_type: 'doc',
Expand Down Expand Up @@ -485,8 +485,8 @@ export const repeatedSearchResultsWithSortId = (
total: number,
pageSize: number,
guids: string[],
ips?: string[],
destIps?: string[]
ips?: Array<string | string[]>,
destIps?: Array<string | string[]>
): SignalSearchResponse => ({
took: 10,
timed_out: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import { flow, omit } from 'lodash/fp';
import set from 'set-value';
import { SearchResponse } from 'elasticsearch';

import { Logger } from '../../../../../../../src/core/server';
import { AlertServices } from '../../../../../alerts/server';
Expand All @@ -15,6 +14,7 @@ import { RuleTypeParams, RefreshTypes } from '../types';
import { singleBulkCreate, SingleBulkCreateResponse } from './single_bulk_create';
import { AnomalyResults, Anomaly } from '../../machine_learning';
import { BuildRuleMessage } from './rule_messages';
import { SearchResponse } from '../../types';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that we're continuing to diverge from the official typings, but I couldn't find an explanation as to why (I was able to revert these without type errors). If we have an unsupported use case we should probably file an issue upstream.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I was playing around with the types as well and forgot to switch this one back...got curious about the duplicates

Copy link
Contributor Author

@marshallmain marshallmain Nov 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I got an error when changing it back and tracked it down. The search response for KQL query rules is typed based on the custom SearchResponse type because it is usinghits.total which looks to be typed (incorrectly) as number in the official SearchResponse type. To share the "filter against lists" code with the KQL rules then the ML bulk create logic has to accept our custom SearchResponse.

Need to investigate further why the upstream SearchResponse.hits.total type doesn't match the expected type based on https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The types package is out of date by over a year.

There's an issue to include up-to-date types with the client, but nothing has been merged. We may be better off preferring to use our own SearchResponse type in general over the unmaintained SearchResponse from ES 5 - until there are centrally maintained response types.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this has been an issue for me in the past too. I think we're on our own until those are updated...


interface BulkCreateMlSignalsParams {
actions: RuleAlertAction[];
Expand Down
Loading