[Security Solution] Consider exceptions when loading threshold alert timelines (#128495)

* Add exceptions to threshold timeline

* Tests and error handling

* Fix unit tests

* Add alias for exceptions filter

* Fix tests

* Type fixes

Co-authored-by: Marshall Main <marshall.main@elastic.co>
This commit is contained in:
Madison Caldwell 2022-03-29 15:09:55 -04:00 committed by GitHub
parent dbaf630535
commit 11bba0a04b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 467 additions and 202 deletions

View file

@ -47,6 +47,7 @@ const ALERT_RULE_CREATED_AT = `${ALERT_RULE_NAMESPACE}.created_at` as const;
const ALERT_RULE_CREATED_BY = `${ALERT_RULE_NAMESPACE}.created_by` as const;
const ALERT_RULE_DESCRIPTION = `${ALERT_RULE_NAMESPACE}.description` as const;
const ALERT_RULE_ENABLED = `${ALERT_RULE_NAMESPACE}.enabled` as const;
const ALERT_RULE_EXCEPTIONS_LIST = `${ALERT_RULE_NAMESPACE}.exceptions_list` as const;
const ALERT_RULE_EXECUTION_UUID = `${ALERT_RULE_NAMESPACE}.execution.uuid` as const;
const ALERT_RULE_FROM = `${ALERT_RULE_NAMESPACE}.from` as const;
const ALERT_RULE_INTERVAL = `${ALERT_RULE_NAMESPACE}.interval` as const;
@ -104,6 +105,7 @@ const fields = {
ALERT_RULE_CREATED_BY,
ALERT_RULE_DESCRIPTION,
ALERT_RULE_ENABLED,
ALERT_RULE_EXCEPTIONS_LIST,
ALERT_RULE_EXECUTION_UUID,
ALERT_RULE_FROM,
ALERT_RULE_INTERVAL,
@ -158,6 +160,7 @@ export {
ALERT_RULE_CREATED_BY,
ALERT_RULE_DESCRIPTION,
ALERT_RULE_ENABLED,
ALERT_RULE_EXCEPTIONS_LIST,
ALERT_RULE_EXECUTION_UUID,
ALERT_RULE_FROM,
ALERT_RULE_INTERVAL,

View file

@ -27,7 +27,7 @@ export type ReturnExceptionListAndItems = [
];
/**
* Hook for using to get an ExceptionList and it's ExceptionListItems
* Hook for using to get an ExceptionList and its ExceptionListItems
*
* @param http Kibana http service
* @param lists array of ExceptionListIdentifiers for all lists to fetch

View file

@ -141,10 +141,12 @@ export const buildExceptionFilter = ({
lists,
excludeExceptions,
chunkSize,
alias = null,
}: {
lists: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>;
excludeExceptions: boolean;
chunkSize: number;
alias: string | null;
}): Filter | undefined => {
// Remove exception items with large value lists. These are evaluated
// elsewhere for the moment being.
@ -154,7 +156,7 @@ export const buildExceptionFilter = ({
const exceptionFilter: Filter = {
meta: {
alias: null,
alias,
disabled: false,
negate: excludeExceptions,
},
@ -195,7 +197,7 @@ export const buildExceptionFilter = ({
return {
meta: {
alias: null,
alias,
disabled: false,
negate: excludeExceptions,
},

View file

@ -45,6 +45,7 @@ describe('build_exceptions_filter', () => {
describe('buildExceptionFilter', () => {
test('it should return undefined if no exception items', () => {
const booleanFilter = buildExceptionFilter({
alias: null,
chunkSize: 1,
excludeExceptions: false,
lists: [],
@ -54,6 +55,7 @@ describe('build_exceptions_filter', () => {
test('it should build a filter given an exception list', () => {
const booleanFilter = buildExceptionFilter({
alias: null,
chunkSize: 1,
excludeExceptions: false,
lists: [getExceptionListItemSchemaMock()],
@ -109,6 +111,7 @@ describe('build_exceptions_filter', () => {
entries: [{ field: 'user.name', operator: 'included', type: 'match', value: 'name' }],
};
const exceptionFilter = buildExceptionFilter({
alias: null,
chunkSize: 2,
excludeExceptions: true,
lists: [exceptionItem1, exceptionItem2],
@ -187,6 +190,7 @@ describe('build_exceptions_filter', () => {
entries: [{ field: 'file.path', operator: 'included', type: 'match', value: '/safe/path' }],
};
const exceptionFilter = buildExceptionFilter({
alias: null,
chunkSize: 2,
excludeExceptions: true,
lists: [exceptionItem1, exceptionItem2, exceptionItem3],
@ -284,6 +288,7 @@ describe('build_exceptions_filter', () => {
];
const booleanFilter = buildExceptionFilter({
alias: null,
chunkSize: 1,
excludeExceptions: true,
lists: exceptions,

View file

@ -43,6 +43,7 @@ export const getQueryFilter = (
lists,
excludeExceptions,
chunkSize: 1024,
alias: null,
});
const initialQuery = { query, language };
const allFilters = getAllFilters(filters as Filter[], exceptionFilter);

View file

@ -8,6 +8,8 @@
import sinon from 'sinon';
import moment from 'moment';
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { sendAlertToTimelineAction, determineToAndFrom } from './actions';
import {
defaultTimelineProps,
@ -34,6 +36,18 @@ import {
DEFAULT_FROM_MOMENT,
DEFAULT_TO_MOMENT,
} from '../../../common/utils/default_date_settings';
import {
COMMENTS,
DATE_NOW,
DESCRIPTION,
ENTRIES,
ITEM_TYPE,
META,
NAME,
NAMESPACE_TYPE,
TIE_BREAKER,
USER,
} from '../../../../../lists/common/constants.mock';
jest.mock('../../../timelines/containers/api', () => ({
getTimelineTemplate: jest.fn(),
@ -41,6 +55,30 @@ jest.mock('../../../timelines/containers/api', () => ({
jest.mock('../../../common/lib/kibana');
export const getExceptionListItemSchemaMock = (
overrides?: Partial<ExceptionListItemSchema>
): ExceptionListItemSchema => ({
_version: undefined,
comments: COMMENTS,
created_at: DATE_NOW,
created_by: USER,
description: DESCRIPTION,
entries: ENTRIES,
id: '1',
item_id: 'endpoint_list_item',
list_id: 'endpoint_list_id',
meta: META,
name: NAME,
namespace_type: NAMESPACE_TYPE,
os_types: [],
tags: ['user added string for a tag', 'malware'],
tie_breaker_id: TIE_BREAKER,
type: ITEM_TYPE,
updated_at: DATE_NOW,
updated_by: USER,
...(overrides || {}),
});
describe('alert actions', () => {
const anchor = '2020-03-01T17:59:46.349Z';
const unix = moment(anchor).valueOf();
@ -49,9 +87,51 @@ describe('alert actions', () => {
let searchStrategyClient: jest.Mocked<ISearchStart>;
let clock: sinon.SinonFakeTimers;
let mockKibanaServices: jest.Mock;
let mockGetExceptions: jest.Mock;
let fetchMock: jest.Mock;
let toastMock: jest.Mock;
const ecsDataMockWithNoTemplateTimeline = getThresholdDetectionAlertAADMock({
...mockAADEcsDataWithAlert,
kibana: {
alert: {
...mockAADEcsDataWithAlert.kibana?.alert,
rule: {
...mockAADEcsDataWithAlert.kibana?.alert?.rule,
parameters: {
...mockAADEcsDataWithAlert.kibana?.alert?.rule?.parameters,
threshold: {
field: ['destination.ip'],
value: 1,
},
},
name: ['mock threshold rule'],
saved_id: [],
type: ['threshold'],
uuid: ['c5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
timeline_id: undefined,
timeline_title: undefined,
},
threshold_result: {
count: 99,
from: '2021-01-10T21:11:45.839Z',
cardinality: [
{
field: 'source.ip',
value: 1,
},
],
terms: [
{
field: 'destination.ip',
value: 1,
},
],
},
},
},
});
beforeEach(() => {
// jest carries state between mocked implementations when using
// spyOn. So now we're doing all three of these.
@ -59,6 +139,7 @@ describe('alert actions', () => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
mockGetExceptions = jest.fn().mockResolvedValue([]);
createTimeline = jest.fn() as jest.Mocked<CreateTimeline>;
updateTimelineIsLoading = jest.fn() as jest.Mocked<UpdateTimelineLoading>;
@ -98,8 +179,10 @@ describe('alert actions', () => {
ecsData: mockEcsDataWithAlert,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(updateTimelineIsLoading).toHaveBeenCalledTimes(1);
expect(updateTimelineIsLoading).toHaveBeenCalledWith({
id: TimelineId.active,
@ -113,6 +196,7 @@ describe('alert actions', () => {
ecsData: mockEcsDataWithAlert,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
const expected = {
from: '2018-11-05T18:58:25.937Z',
@ -248,6 +332,7 @@ describe('alert actions', () => {
ruleNote: '# this is some markdown documentation',
};
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledWith(expected);
});
@ -269,9 +354,11 @@ describe('alert actions', () => {
ecsData: mockEcsDataWithAlert,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
const createTimelineArg = (createTimeline as jest.Mock).mock.calls[0][0];
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimelineArg.timeline.kqlQuery.filterQuery.kuery.kind).toEqual('kuery');
});
@ -286,6 +373,7 @@ describe('alert actions', () => {
ecsData: mockEcsDataWithAlert,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
const defaultTimelinePropsWithoutNote = { ...defaultTimelineProps };
@ -299,6 +387,7 @@ describe('alert actions', () => {
id: TimelineId.active,
isLoading: false,
});
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith({
...defaultTimelinePropsWithoutNote,
@ -331,9 +420,11 @@ describe('alert actions', () => {
ecsData: ecsDataMock,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
expect(updateTimelineIsLoading).not.toHaveBeenCalled();
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith(defaultTimelineProps);
});
@ -356,9 +447,11 @@ describe('alert actions', () => {
ecsData: ecsDataMock,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
expect(updateTimelineIsLoading).not.toHaveBeenCalled();
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith(defaultTimelineProps);
});
@ -385,9 +478,11 @@ describe('alert actions', () => {
ecsData: ecsDataMock,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
expect(updateTimelineIsLoading).not.toHaveBeenCalled();
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith({
...defaultTimelineProps,
@ -426,215 +521,260 @@ describe('alert actions', () => {
ecsData: ecsDataMock,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
expect(updateTimelineIsLoading).not.toHaveBeenCalled();
expect(mockGetExceptions).not.toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith(defaultTimelineProps);
});
});
});
describe('determineToAndFrom', () => {
const ecsDataMockWithNoTemplateTimeline = getThresholdDetectionAlertAADMock({
...mockAADEcsDataWithAlert,
kibana: {
alert: {
...mockAADEcsDataWithAlert.kibana?.alert,
rule: {
...mockAADEcsDataWithAlert.kibana?.alert?.rule,
parameters: {
...mockAADEcsDataWithAlert.kibana?.alert?.rule?.parameters,
threshold: {
field: ['destination.ip'],
value: 1,
},
},
name: ['mock threshold rule'],
saved_id: [],
type: ['threshold'],
uuid: ['c5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
timeline_id: undefined,
timeline_title: undefined,
},
threshold_result: {
count: 99,
from: '2021-01-10T21:11:45.839Z',
cardinality: [
describe('Threshold', () => {
beforeEach(() => {
fetchMock.mockResolvedValue({
hits: {
hits: [
{
field: 'source.ip',
value: 1,
},
],
terms: [
{
field: 'destination.ip',
value: 1,
_id: ecsDataMockWithNoTemplateTimeline[0]._id,
_index: 'mock',
_source: ecsDataMockWithNoTemplateTimeline[0],
},
],
},
},
},
});
beforeEach(() => {
fetchMock.mockResolvedValue({
hits: {
hits: [
{
_id: ecsDataMockWithNoTemplateTimeline[0]._id,
_index: 'mock',
_source: ecsDataMockWithNoTemplateTimeline[0],
},
],
},
});
});
});
test('it uses ecs.Data.timestamp if one is provided', () => {
const ecsDataMock: Ecs = {
...mockEcsDataWithAlert,
timestamp: '2020-03-20T17:59:46.349Z',
};
const result = determineToAndFrom({ ecs: ecsDataMock });
expect(result.from).toEqual('2020-03-20T17:54:46.349Z');
expect(result.to).toEqual('2020-03-20T17:59:46.349Z');
});
test('Exceptions are included', async () => {
mockGetExceptions.mockResolvedValue([getExceptionListItemSchemaMock()]);
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsDataMockWithNoTemplateTimeline,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
test('it uses current time timestamp if ecsData.timestamp is not provided', () => {
const { timestamp, ...ecsDataMock } = mockEcsDataWithAlert;
const result = determineToAndFrom({ ecs: ecsDataMock });
const expectedFrom = '2021-01-10T21:11:45.839Z';
const expectedTo = '2021-01-10T21:12:45.839Z';
expect(result.from).toEqual('2020-03-01T17:54:46.349Z');
expect(result.to).toEqual('2020-03-01T17:59:46.349Z');
});
test('it uses original_time and threshold_result.from for threshold alerts', async () => {
const expectedFrom = '2021-01-10T21:11:45.839Z';
const expectedTo = '2021-01-10T21:12:45.839Z';
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsDataMockWithNoTemplateTimeline,
updateTimelineIsLoading,
searchStrategyClient,
});
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith({
...defaultTimelineProps,
timeline: {
...defaultTimelineProps.timeline,
dataProviders: [
{
and: [],
enabled: true,
excluded: false,
id: 'send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-destination-ip-1',
kqlQuery: '',
name: 'destination.ip',
queryMatch: { field: 'destination.ip', operator: ':', value: 1 },
},
],
dateRange: {
start: expectedFrom,
end: expectedTo,
},
description: '_id: 1',
kqlQuery: {
filterQuery: {
kuery: {
expression: ['user.id:1'],
kind: ['kuery'],
},
serializedQuery: ['user.id:1'],
},
},
resolveTimelineConfig: undefined,
},
from: expectedFrom,
to: expectedTo,
});
});
});
describe('show toasts when data is malformed', () => {
const ecsDataMockWithNoTemplateTimeline = getThresholdDetectionAlertAADMock({
...mockAADEcsDataWithAlert,
kibana: {
alert: {
...mockAADEcsDataWithAlert.kibana?.alert,
rule: {
...mockAADEcsDataWithAlert.kibana?.alert?.rule,
parameters: {
...mockAADEcsDataWithAlert.kibana?.alert?.rule?.parameters,
threshold: {
field: ['destination.ip'],
value: 1,
},
},
name: ['mock threshold rule'],
saved_id: [],
type: ['threshold'],
uuid: ['c5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
timeline_id: undefined,
timeline_title: undefined,
},
threshold_result: {
count: 99,
from: '2021-01-10T21:11:45.839Z',
cardinality: [
expect(updateTimelineIsLoading).not.toHaveBeenCalled();
expect(mockGetExceptions).toHaveBeenCalled();
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith({
...defaultTimelineProps,
timeline: {
...defaultTimelineProps.timeline,
dataProviders: [
{
field: 'source.ip',
value: 1,
and: [],
enabled: true,
excluded: false,
id: 'send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-destination-ip-1',
kqlQuery: '',
name: 'destination.ip',
queryMatch: { field: 'destination.ip', operator: ':', value: 1 },
},
],
terms: [
dateRange: {
start: expectedFrom,
end: expectedTo,
},
description: '_id: 1',
filters: [
{
field: 'destination.ip',
value: 1,
meta: {
alias: 'Exceptions',
disabled: false,
negate: true,
},
query: {
bool: {
should: [
{
bool: {
filter: [
{
nested: {
path: 'some.parentField',
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'some.parentField.nested.field': 'some value',
},
},
],
},
},
score_mode: 'none',
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'some.not.nested.field': 'some value',
},
},
],
},
},
],
},
},
],
},
},
},
],
kqlQuery: {
filterQuery: {
kuery: {
expression: ['user.id:1'],
kind: ['kuery'],
},
serializedQuery: ['user.id:1'],
},
},
resolveTimelineConfig: undefined,
},
from: expectedFrom,
to: expectedTo,
});
});
});
describe('determineToAndFrom', () => {
beforeEach(() => {
fetchMock.mockResolvedValue({
hits: {
hits: [
{
_id: ecsDataMockWithNoTemplateTimeline[0]._id,
_index: 'mock',
_source: ecsDataMockWithNoTemplateTimeline[0],
},
],
},
},
},
});
beforeEach(() => {
fetchMock.mockResolvedValue({
hits: 'not correctly formed doc',
});
});
test('it uses ecs.Data.timestamp if one is provided', () => {
const ecsDataMock: Ecs = {
...mockEcsDataWithAlert,
timestamp: '2020-03-20T17:59:46.349Z',
};
const result = determineToAndFrom({ ecs: ecsDataMock });
expect(result.from).toEqual('2020-03-20T17:54:46.349Z');
expect(result.to).toEqual('2020-03-20T17:59:46.349Z');
});
test('it uses current time timestamp if ecsData.timestamp is not provided', () => {
const { timestamp, ...ecsDataMock } = mockEcsDataWithAlert;
const result = determineToAndFrom({ ecs: ecsDataMock });
expect(result.from).toEqual('2020-03-01T17:54:46.349Z');
expect(result.to).toEqual('2020-03-01T17:59:46.349Z');
});
test('it uses original_time and threshold_result.from for threshold alerts', async () => {
const expectedFrom = '2021-01-10T21:11:45.839Z';
const expectedTo = '2021-01-10T21:12:45.839Z';
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsDataMockWithNoTemplateTimeline,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith({
...defaultTimelineProps,
timeline: {
...defaultTimelineProps.timeline,
dataProviders: [
{
and: [],
enabled: true,
excluded: false,
id: 'send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-destination-ip-1',
kqlQuery: '',
name: 'destination.ip',
queryMatch: { field: 'destination.ip', operator: ':', value: 1 },
},
],
dateRange: {
start: expectedFrom,
end: expectedTo,
},
description: '_id: 1',
kqlQuery: {
filterQuery: {
kuery: {
expression: ['user.id:1'],
kind: ['kuery'],
},
serializedQuery: ['user.id:1'],
},
},
resolveTimelineConfig: undefined,
},
from: expectedFrom,
to: expectedTo,
});
});
});
test('renders a toast and calls create timeline with basic defaults', async () => {
const expectedFrom = DEFAULT_FROM_MOMENT.toISOString();
const expectedTo = DEFAULT_TO_MOMENT.toISOString();
const timelineProps = {
...defaultTimelineProps,
timeline: {
...defaultTimelineProps.timeline,
dataProviders: [],
dateRange: {
start: expectedFrom,
end: expectedTo,
},
description: '',
kqlQuery: {
filterQuery: null,
},
resolveTimelineConfig: undefined,
},
from: expectedFrom,
to: expectedTo,
};
delete timelineProps.ruleNote;
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsDataMockWithNoTemplateTimeline,
updateTimelineIsLoading,
searchStrategyClient,
describe('show toasts when data is malformed', () => {
beforeEach(() => {
fetchMock.mockResolvedValue({
hits: 'not correctly formed doc',
});
});
test('renders a toast and calls create timeline with basic defaults', async () => {
const expectedFrom = DEFAULT_FROM_MOMENT.toISOString();
const expectedTo = DEFAULT_TO_MOMENT.toISOString();
const timelineProps = {
...defaultTimelineProps,
timeline: {
...defaultTimelineProps.timeline,
dataProviders: [],
dateRange: {
start: expectedFrom,
end: expectedTo,
},
description: '',
kqlQuery: {
filterQuery: null,
},
resolveTimelineConfig: undefined,
},
from: expectedFrom,
to: expectedTo,
};
delete timelineProps.ruleNote;
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsDataMockWithNoTemplateTimeline,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions: mockGetExceptions,
});
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith(timelineProps);
expect(toastMock).toHaveBeenCalled();
});
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith(timelineProps);
expect(toastMock).toHaveBeenCalled();
});
});
});

View file

@ -11,9 +11,11 @@ import { getOr, isEmpty } from 'lodash/fp';
import moment from 'moment';
import dateMath from '@elastic/datemath';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { FilterStateStore, Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import {
ALERT_RULE_FROM,
ALERT_RULE_TYPE,
@ -21,7 +23,9 @@ import {
ALERT_RULE_PARAMETERS,
} from '@kbn/rule-data-utils';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { buildExceptionFilter } from '@kbn/securitysolution-list-utils';
import {
ALERT_ORIGINAL_TIME,
ALERT_GROUP_ID,
@ -265,14 +269,14 @@ export const getThresholdAggregationData = (ecsData: Ecs | Ecs[]): ThresholdAggr
);
};
export const isEqlRuleWithGroupId = (ecsData: Ecs): boolean => {
export const isEqlAlertWithGroupId = (ecsData: Ecs): boolean => {
const ruleType = getField(ecsData, ALERT_RULE_TYPE);
const groupId = getField(ecsData, ALERT_GROUP_ID);
const isEql = ruleType === 'eql' || (Array.isArray(ruleType) && ruleType[0] === 'eql');
return isEql && groupId?.length > 0;
};
export const isThresholdRule = (ecsData: Ecs): boolean => {
export const isThresholdAlert = (ecsData: Ecs): boolean => {
const ruleType = getField(ecsData, ALERT_RULE_TYPE);
return (
ruleType === 'threshold' ||
@ -396,7 +400,8 @@ const createThresholdTimeline = async (
ecsData: Ecs,
createTimeline: ({ from, timeline, to }: CreateTimelineProps) => void,
noteContent: string,
templateValues: { filters?: Filter[]; query?: string; dataProviders?: DataProvider[] }
templateValues: { filters?: Filter[]; query?: string; dataProviders?: DataProvider[] },
getExceptions: (ecs: Ecs) => Promise<ExceptionListItemSchema[]>
) => {
try {
const alertResponse = await KibanaServices.get().http.fetch<
@ -417,6 +422,7 @@ const createThresholdTimeline = async (
},
];
}, []) ?? [];
const alertDoc = formattedAlertData[0];
const params = getField(alertDoc, ALERT_RULE_PARAMETERS);
const filters = getFiltersFromRule(params.filters ?? alertDoc.signal?.rule?.filters) ?? [];
@ -425,13 +431,23 @@ const createThresholdTimeline = async (
const indexNames = params.index ?? alertDoc.signal?.rule?.index ?? [];
const { thresholdFrom, thresholdTo, dataProviders } = getThresholdAggregationData(alertDoc);
const exceptions = await getExceptions(ecsData);
const exceptionsFilter =
buildExceptionFilter({
lists: exceptions,
excludeExceptions: true,
chunkSize: 10000,
alias: 'Exceptions',
}) ?? [];
const allFilters = (templateValues.filters ?? filters).concat(exceptionsFilter);
return createTimeline({
from: thresholdFrom,
notes: null,
timeline: {
...timelineDefaults,
description: `_id: ${alertDoc._id}`,
filters: templateValues.filters ?? filters,
filters: allFilters,
dataProviders: templateValues.dataProviders ?? dataProviders,
id: TimelineId.active,
indexNames,
@ -495,6 +511,7 @@ export const sendAlertToTimelineAction = async ({
ecsData: ecs,
updateTimelineIsLoading,
searchStrategyClient,
getExceptions,
}: SendAlertToTimelineActionProps) => {
/* FUTURE DEVELOPER
* We are making an assumption here that if you have an array of ecs data they are all coming from the same rule
@ -554,12 +571,18 @@ export const sendAlertToTimelineAction = async ({
timeline.timelineType
);
// threshold with template
if (isThresholdRule(ecsData)) {
return createThresholdTimeline(ecsData, createTimeline, noteContent, {
filters,
query,
dataProviders,
});
if (isThresholdAlert(ecsData)) {
return createThresholdTimeline(
ecsData,
createTimeline,
noteContent,
{
filters,
query,
dataProviders,
},
getExceptions
);
} else {
return createTimeline({
from,
@ -612,11 +635,11 @@ export const sendAlertToTimelineAction = async ({
to,
});
}
} else if (isThresholdRule(ecsData)) {
return createThresholdTimeline(ecsData, createTimeline, noteContent, {});
} else if (isThresholdAlert(ecsData)) {
return createThresholdTimeline(ecsData, createTimeline, noteContent, {}, getExceptions);
} else {
let { dataProviders, filters } = buildTimelineDataProviderOrFilter(alertIds ?? [], ecsData._id);
if (isEqlRuleWithGroupId(ecsData)) {
if (isEqlAlertWithGroupId(ecsData)) {
const tempEql = buildEqlDataProviderOrFilter(alertIds ?? [], ecs);
dataProviders = tempEql.dataProviders;
filters = tempEql.filters;

View file

@ -6,14 +6,16 @@
*/
import { isEmpty } from 'lodash/fp';
import { Filter, FilterStateStore, KueryNode, fromKueryExpression } from '@kbn/es-query';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TimelineType } from '../../../../common/types/timeline';
import {
DataProvider,
DataProviderType,
DataProvidersAnd,
} from '../../../timelines/components/timeline/data_providers/data_provider';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TimelineType } from '../../../../common/types/timeline';
interface FindValueToChangeInQuery {
field: string;

View file

@ -13,6 +13,7 @@ import * as actions from '../actions';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import type { SendAlertToTimelineActionProps } from '../types';
import { InvestigateInTimelineAction } from './investigate_in_timeline_action';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
const ecsRowData: Ecs = {
_id: '1',
@ -29,6 +30,7 @@ const ecsRowData: Ecs = {
};
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/hooks/use_app_toasts');
jest.mock('../actions');
const props = {
@ -54,6 +56,9 @@ describe('use investigate in timeline hook', () => {
},
},
});
(useAppToasts as jest.Mock).mockReturnValue({
addError: jest.fn(),
});
});
afterEach(() => {
jest.resetAllMocks();

View file

@ -13,6 +13,7 @@ import { useInvestigateInTimeline } from './use_investigate_in_timeline';
import * as actions from '../actions';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import type { SendAlertToTimelineActionProps } from '../types';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
const ecsRowData: Ecs = {
_id: '1',
@ -29,6 +30,7 @@ const ecsRowData: Ecs = {
};
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/hooks/use_app_toasts');
jest.mock('../actions');
const props = {
@ -53,6 +55,9 @@ describe('use investigate in timeline hook', () => {
},
},
});
(useAppToasts as jest.Mock).mockReturnValue({
addError: jest.fn(),
});
});
afterEach(() => {
jest.resetAllMocks();

View file

@ -8,8 +8,17 @@ import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { EuiContextMenuItem } from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { i18n } from '@kbn/i18n';
import { ALERT_RULE_EXCEPTIONS_LIST } from '@kbn/rule-data-utils';
import {
ExceptionListIdentifiers,
ExceptionListItemSchema,
ReadExceptionListSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { useApi } from '@kbn/securitysolution-list-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineId, TimelineType } from '../../../../../common/types/timeline';
import { Ecs } from '../../../../../common/ecs';
import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline';
@ -19,6 +28,8 @@ import { useCreateTimeline } from '../../../../timelines/components/timeline/pro
import { CreateTimelineProps } from '../types';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../translations';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { getField } from '../../../../helpers';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
interface UseInvestigateInTimelineActionProps {
ecsRowData?: Ecs | Ecs[] | null;
@ -29,11 +40,65 @@ export const useInvestigateInTimeline = ({
ecsRowData,
onInvestigateInTimelineAlertClick,
}: UseInvestigateInTimelineActionProps) => {
const { addError } = useAppToasts();
const {
data: { search: searchStrategyClient, query },
} = useKibana().services;
const dispatch = useDispatch();
const { services } = useKibana();
const { getExceptionListsItems } = useApi(services.http);
const getExceptions = useCallback(
async (ecsData: Ecs): Promise<ExceptionListItemSchema[]> => {
const exceptionsLists: ReadExceptionListSchema[] = (
getField(ecsData, ALERT_RULE_EXCEPTIONS_LIST) ?? []
)
.map((list: string) => JSON.parse(list))
.filter((list: ExceptionListIdentifiers) => list.type === 'detection');
const allExceptions: ExceptionListItemSchema[] = [];
if (exceptionsLists.length > 0) {
for (const list of exceptionsLists) {
if (list.id && list.list_id && list.namespace_type) {
await getExceptionListsItems({
lists: [
{
id: list.id,
listId: list.list_id,
type: 'detection',
namespaceType: list.namespace_type,
},
],
filterOptions: [],
pagination: {
page: 0,
perPage: 10000,
total: 10000,
},
showDetectionsListsOnly: true,
showEndpointListsOnly: false,
onSuccess: ({ exceptions }) => {
allExceptions.push(...exceptions);
},
onError: (err: string[]) => {
addError(err, {
title: i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.fetchExceptionsFailure',
{ defaultMessage: 'Error fetching exceptions.' }
),
});
},
});
}
}
}
return allExceptions;
},
[addError, getExceptionListsItems]
);
const filterManagerBackup = useMemo(() => query.filterManager, [query.filterManager]);
const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []);
const { filterManager: activeFilterManager } = useDeepEqualSelector((state) =>
@ -86,6 +151,7 @@ export const useInvestigateInTimeline = ({
ecsData: ecsRowData,
searchStrategyClient,
updateTimelineIsLoading,
getExceptions,
});
}
}, [
@ -94,6 +160,7 @@ export const useInvestigateInTimeline = ({
onInvestigateInTimelineAlertClick,
searchStrategyClient,
updateTimelineIsLoading,
getExceptions,
]);
const investigateInTimelineActionItems = useMemo(

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import type { ISearchStart } from '../../../../../../../src/plugins/data/public';
import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { Ecs } from '../../../../common/ecs';
@ -55,6 +57,7 @@ export interface SendAlertToTimelineActionProps {
ecsData: Ecs | Ecs[];
updateTimelineIsLoading: UpdateTimelineLoading;
searchStrategyClient: ISearchStart;
getExceptions: GetExceptions;
}
export type UpdateTimelineLoading = ({ id, isLoading }: { id: string; isLoading: boolean }) => void;
@ -68,6 +71,7 @@ export interface CreateTimelineProps {
}
export type CreateTimeline = ({ from, timeline, to }: CreateTimelineProps) => void;
export type GetExceptions = (ecsData: Ecs) => Promise<ExceptionListItemSchema[]>;
export interface ThresholdAggregationData {
thresholdFrom: string;

View file

@ -35,6 +35,12 @@ jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () =
}));
jest.mock('../../../cases/components/use_insert_timeline');
jest.mock('../../../common/hooks/use_app_toasts', () => ({
useAppToasts: jest.fn().mockReturnValue({
addError: jest.fn(),
}),
}));
jest.mock('../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));

View file

@ -193,6 +193,7 @@ export const buildEqlSearchRequest = (
lists: exceptionLists,
excludeExceptions: true,
chunkSize: 1024,
alias: null,
});
const rangeFilter = buildTimeRangeFilter({ to, from, timestampOverride });

View file

@ -57,6 +57,7 @@ export const getAnomalies = async (
lists: params.exceptionItems,
excludeExceptions: true,
chunkSize: 1024,
alias: null,
})?.query,
},
},