[Security Solution] Open alerts with an associated template in the template view (#123333) (#123689)

* Open alerts with a template, with a template

* Add default values back instead of template derived ones

* Use data providers over filters always, set timeline description to alert id

* Remove prepopulated description from non threshold alerts

* Open any event in timeline, use correct timestamp

* Remove unneeded @timestamp, make sure alertsEcsData is not empty array

* Add basic getField tests

* Explicity check if alertGroupId is an array instead of using length

* Always use a valid date for time range

* Only use filter if more than 1 alert is present

* Possibly controversial change to calculate threshold time range with a template, fix test that should never have passed

* Create threshold timeline in separate function

* Use better type for createTimeline passed to createThresholdTimeline

* Invert negation as suggested in pr comment

* Use template timeline filters/query/data providers for threshold alerts

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
(cherry picked from commit cef886f073)

Co-authored-by: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2022-01-25 03:29:21 -05:00 committed by GitHub
parent 1b46b68464
commit 2325ed6a22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 264 additions and 127 deletions

View file

@ -15,6 +15,7 @@ import {
mockEcsDataWithAlert,
mockTimelineDetails,
mockTimelineResult,
mockAADEcsDataWithAlert,
} from '../../../common/mock/';
import { CreateTimeline, UpdateTimelineLoading } from './types';
import { Ecs } from '../../../../common/ecs';
@ -268,6 +269,9 @@ describe('alert actions', () => {
updateTimelineIsLoading,
searchStrategyClient,
});
const defaultTimelinePropsWithoutNote = { ...defaultTimelineProps };
delete defaultTimelinePropsWithoutNote.ruleNote;
expect(updateTimelineIsLoading).toHaveBeenCalledWith({
id: TimelineId.active,
@ -278,7 +282,17 @@ describe('alert actions', () => {
isLoading: false,
});
expect(createTimeline).toHaveBeenCalledTimes(1);
expect(createTimeline).toHaveBeenCalledWith(defaultTimelineProps);
expect(createTimeline).toHaveBeenCalledWith({
...defaultTimelinePropsWithoutNote,
timeline: {
...defaultTimelinePropsWithoutNote.timeline,
dataProviders: [],
kqlQuery: {
filterQuery: null,
},
resolveTimelineConfig: undefined,
},
});
});
});
@ -289,8 +303,7 @@ describe('alert actions', () => {
signal: {
rule: {
...mockEcsDataWithAlert.signal?.rule,
// @ts-expect-error
timeline_id: null,
timeline_id: [''],
},
},
};
@ -362,6 +375,7 @@ describe('alert actions', () => {
...defaultTimelineProps,
timeline: {
...defaultTimelineProps.timeline,
resolveTimelineConfig: undefined,
dataProviders: [
{
and: [],
@ -424,14 +438,53 @@ describe('alert actions', () => {
});
test('it uses original_time and threshold_result.from for threshold alerts', async () => {
const ecsDataMock = getThresholdDetectionAlertAADMock();
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,
},
],
},
},
},
});
const expectedFrom = '2021-01-10T21:11:45.839Z';
const expectedTo = '2021-01-10T21:12:45.839Z';
await sendAlertToTimelineAction({
createTimeline,
ecsData: ecsDataMock,
ecsData: ecsDataMockWithNoTemplateTimeline,
updateTimelineIsLoading,
searchStrategyClient,
});

View file

@ -38,6 +38,7 @@ import {
SendAlertToTimelineActionProps,
ThresholdAggregationData,
UpdateAlertStatusActionProps,
CreateTimelineProps,
} from './types';
import { Ecs } from '../../../../common/ecs';
import {
@ -121,11 +122,9 @@ export const updateAlertStatusAction = async ({
export const determineToAndFrom = ({ ecs }: { ecs: Ecs[] | Ecs }) => {
if (Array.isArray(ecs)) {
const timestamps = ecs.reduce<number[]>((acc, item) => {
if (item.timestamp != null) {
const dateTimestamp = new Date(item.timestamp);
if (!acc.includes(dateTimestamp.valueOf())) {
return [...acc, dateTimestamp.valueOf()];
}
const dateTimestamp = item.timestamp ? new Date(item.timestamp) : new Date();
if (!acc.includes(dateTimestamp.valueOf())) {
return [...acc, dateTimestamp.valueOf()];
}
return acc;
}, []);
@ -137,12 +136,12 @@ export const determineToAndFrom = ({ ecs }: { ecs: Ecs[] | Ecs }) => {
const ecsData = ecs as Ecs;
const ruleFrom = getField(ecsData, ALERT_RULE_FROM);
const elapsedTimeRule = moment.duration(
moment().diff(dateMath.parse(ruleFrom != null ? ruleFrom[0] : 'now-0s'))
moment().diff(dateMath.parse(ruleFrom != null ? ruleFrom[0] : 'now-1d'))
);
const from = moment(ecsData?.timestamp ?? new Date())
const from = moment(ecsData.timestamp ?? new Date())
.subtract(elapsedTimeRule)
.toISOString();
const to = moment(ecsData?.timestamp ?? new Date()).toISOString();
const to = moment(ecsData.timestamp ?? new Date()).toISOString();
return { to, from };
};
@ -258,17 +257,18 @@ export const getThresholdAggregationData = (ecsData: Ecs | Ecs[]): ThresholdAggr
);
};
export const isEqlRuleWithGroupId = (ecsData: Ecs) => {
export const isEqlRuleWithGroupId = (ecsData: Ecs): boolean => {
const ruleType = getField(ecsData, ALERT_RULE_TYPE);
const groupId = getField(ecsData, ALERT_GROUP_ID);
return ruleType?.length && ruleType[0] === 'eql' && groupId?.length;
const isEql = ruleType === 'eql' || (Array.isArray(ruleType) && ruleType[0] === 'eql');
return isEql && groupId?.length > 0;
};
export const isThresholdRule = (ecsData: Ecs) => {
export const isThresholdRule = (ecsData: Ecs): boolean => {
const ruleType = getField(ecsData, ALERT_RULE_TYPE);
return (
ruleType === 'threshold' ||
(Array.isArray(ruleType) && ruleType.length && ruleType[0] === 'threshold')
(Array.isArray(ruleType) && ruleType.length > 0 && ruleType[0] === 'threshold')
);
};
@ -303,50 +303,51 @@ export const buildAlertsKqlFilter = (
];
};
export const buildTimelineDataProviderOrFilter = (
alertsIds: string[],
const buildTimelineDataProviderOrFilter = (
alertIds: string[],
_id: string
): { filters: Filter[]; dataProviders: DataProvider[] } => {
if (!isEmpty(alertsIds)) {
if (!isEmpty(alertIds) && Array.isArray(alertIds) && alertIds.length > 1) {
return {
filters: buildAlertsKqlFilter('_id', alertIds),
dataProviders: [],
filters: buildAlertsKqlFilter('_id', alertsIds),
};
} else {
return {
filters: [],
dataProviders: [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${_id}`,
name: _id,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: '_id',
value: _id,
operator: ':' as const,
},
},
],
};
}
return {
filters: [],
dataProviders: [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${_id}`,
name: _id,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: '_id',
value: _id,
operator: ':' as const,
},
},
],
};
};
export const buildEqlDataProviderOrFilter = (
alertsIds: string[],
const buildEqlDataProviderOrFilter = (
alertIds: string[],
ecs: Ecs[] | Ecs
): { filters: Filter[]; dataProviders: DataProvider[] } => {
if (!isEmpty(alertsIds) && Array.isArray(ecs)) {
if (!isEmpty(alertIds) && Array.isArray(ecs) && ecs.length > 1) {
return {
dataProviders: [],
filters: buildAlertsKqlFilter(
'signal.group.id',
ALERT_GROUP_ID,
ecs.reduce<string[]>((acc, ecsData) => {
const alertGroupIdField = getField(ecsData, ALERT_GROUP_ID);
const alertGroupId = alertGroupIdField?.length
const alertGroupId = Array.isArray(alertGroupIdField)
? alertGroupIdField[0]
: 'unknown-group-id';
: alertGroupIdField;
if (!acc.includes(alertGroupId)) {
return [...acc, alertGroupId];
}
@ -354,16 +355,19 @@ export const buildEqlDataProviderOrFilter = (
}, [])
),
};
} else if (!Array.isArray(ecs)) {
const alertGroupIdField: string[] = getField(ecs, ALERT_GROUP_ID);
const queryMatchField = getFieldKey(ecs, ALERT_GROUP_ID);
const alertGroupId = alertGroupIdField?.length ? alertGroupIdField[0] : 'unknown-group-id';
} else if (!Array.isArray(ecs) || ecs.length === 1) {
const ecsData = Array.isArray(ecs) ? ecs[0] : ecs;
const alertGroupIdField = getField(ecsData, ALERT_GROUP_ID);
const queryMatchField = getFieldKey(ecsData, ALERT_GROUP_ID);
const alertGroupId = Array.isArray(alertGroupIdField)
? alertGroupIdField[0]
: alertGroupIdField;
return {
dataProviders: [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${alertGroupId}`,
name: ecs._id,
name: ecsData._id,
enabled: true,
excluded: false,
kqlQuery: '',
@ -380,6 +384,49 @@ export const buildEqlDataProviderOrFilter = (
return { filters: [], dataProviders: [] };
};
const createThresholdTimeline = (
ecsData: Ecs,
createTimeline: ({ from, timeline, to }: CreateTimelineProps) => void,
noteContent: string,
templateValues: { filters?: Filter[]; query?: string; dataProviders?: DataProvider[] }
) => {
const { thresholdFrom, thresholdTo, dataProviders } = getThresholdAggregationData(ecsData);
const params = getField(ecsData, ALERT_RULE_PARAMETERS);
const filters = getFiltersFromRule(params.filters ?? ecsData.signal?.rule?.filters) ?? [];
const language = params.language ?? ecsData.signal?.rule?.language ?? 'kuery';
const query = params.query ?? ecsData.signal?.rule?.query ?? '';
const indexNames = params.index ?? ecsData.signal?.rule?.index ?? [];
return createTimeline({
from: thresholdFrom,
notes: null,
timeline: {
...timelineDefaults,
description: `_id: ${ecsData._id}`,
filters: templateValues.filters ?? filters,
dataProviders: templateValues.dataProviders ?? dataProviders,
id: TimelineId.active,
indexNames,
dateRange: {
start: thresholdFrom,
end: thresholdTo,
},
eventType: 'all',
kqlQuery: {
filterQuery: {
kuery: {
kind: language,
expression: templateValues.query ?? query,
},
serializedQuery: templateValues.query ?? query,
},
},
},
to: thresholdTo,
ruleNote: noteContent,
});
};
export const sendAlertToTimelineAction = async ({
createTimeline,
ecsData: ecs,
@ -395,12 +442,15 @@ export const sendAlertToTimelineAction = async ({
const ruleNote = getField(ecsData, ALERT_RULE_NOTE);
const noteContent = Array.isArray(ruleNote) && ruleNote.length > 0 ? ruleNote[0] : '';
const ruleTimelineId = getField(ecsData, ALERT_RULE_TIMELINE_ID);
const timelineId =
Array.isArray(ruleTimelineId) && ruleTimelineId.length > 0 ? ruleTimelineId[0] : '';
const timelineId = !isEmpty(ruleTimelineId)
? Array.isArray(ruleTimelineId)
? ruleTimelineId[0]
: ruleTimelineId
: '';
const { to, from } = determineToAndFrom({ ecs });
// For now we do not want to populate the template timeline if we have alertIds
if (!isEmpty(timelineId) && isEmpty(alertIds)) {
if (!isEmpty(timelineId)) {
try {
updateTimelineIsLoading({ id: TimelineId.active, isLoading: true });
const [responseTimeline, eventDataResp] = await Promise.all([
@ -440,81 +490,67 @@ export const sendAlertToTimelineAction = async ({
eventData,
timeline.timelineType
);
return createTimeline({
from,
timeline: {
...timeline,
title: '',
timelineType: TimelineType.default,
templateTimelineId: null,
status: TimelineStatus.draft,
dataProviders,
eventType: 'all',
// threshold with template
if (isThresholdRule(ecsData)) {
createThresholdTimeline(ecsData, createTimeline, noteContent, {
filters,
dateRange: {
start: from,
end: to,
},
kqlQuery: {
filterQuery: {
kuery: {
kind: timeline.kqlQuery?.filterQuery?.kuery?.kind ?? 'kuery',
expression: query,
},
serializedQuery: convertKueryToElasticSearchQuery(query),
query,
dataProviders,
});
} else {
return createTimeline({
from,
timeline: {
...timeline,
title: '',
timelineType: TimelineType.default,
templateTimelineId: null,
status: TimelineStatus.draft,
dataProviders,
eventType: 'all',
filters,
dateRange: {
start: from,
end: to,
},
kqlQuery: {
filterQuery: {
kuery: {
kind: timeline.kqlQuery?.filterQuery?.kuery?.kind ?? 'kuery',
expression: query,
},
serializedQuery: convertKueryToElasticSearchQuery(query),
},
},
noteIds: notes?.map((n) => n.noteId) ?? [],
show: true,
},
noteIds: notes?.map((n) => n.noteId) ?? [],
show: true,
},
to,
ruleNote: noteContent,
notes: notes ?? null,
});
to,
ruleNote: noteContent,
notes: notes ?? null,
});
}
}
} catch {
updateTimelineIsLoading({ id: TimelineId.active, isLoading: false });
}
}
if (isThresholdRule(ecsData)) {
const { thresholdFrom, thresholdTo, dataProviders } = getThresholdAggregationData(ecsData);
const params = getField(ecsData, ALERT_RULE_PARAMETERS);
const filters = getFiltersFromRule(params.filters ?? ecsData.signal?.rule?.filters) ?? [];
const language = params.language ?? ecsData.signal?.rule?.language ?? 'kuery';
const query = params.query ?? ecsData.signal?.rule?.query ?? '';
const indexNames = params.index ?? ecsData.signal?.rule?.index ?? [];
return createTimeline({
from: thresholdFrom,
notes: null,
timeline: {
...timelineDefaults,
description: `_id: ${ecsData._id}`,
filters,
dataProviders,
id: TimelineId.active,
indexNames,
dateRange: {
start: thresholdFrom,
end: thresholdTo,
},
eventType: 'all',
kqlQuery: {
filterQuery: {
kuery: {
kind: language,
expression: query,
},
serializedQuery: query,
return createTimeline({
from,
notes: null,
timeline: {
...timelineDefaults,
id: TimelineId.active,
indexNames: [],
dateRange: {
start: from,
end: to,
},
eventType: 'all',
},
},
to: thresholdTo,
ruleNote: noteContent,
});
to,
});
}
} else if (isThresholdRule(ecsData)) {
createThresholdTimeline(ecsData, createTimeline, noteContent, {});
} else {
let { dataProviders, filters } = buildTimelineDataProviderOrFilter(alertIds ?? [], ecsData._id);
if (isEqlRuleWithGroupId(ecsData)) {

View file

@ -6,6 +6,7 @@
*/
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { isEmpty } from 'lodash';
import { EuiContextMenuItem } from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
@ -84,7 +85,7 @@ export const useInvestigateInTimeline = ({
if (onInvestigateInTimelineAlertClick) {
onInvestigateInTimelineAlertClick();
}
if (alertsEcsData != null) {
if (!isEmpty(alertsEcsData) && alertsEcsData !== null) {
await sendAlertToTimelineAction({
createTimeline,
ecsData: alertsEcsData,

View file

@ -8,12 +8,15 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Capabilities } from '../../../../src/core/public';
import { CASES_FEATURE_ID, SERVER_APP_ID } from '../common/constants';
import { mockEcsDataWithAlert } from './common/mock';
import { ALERT_RULE_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import {
parseRoute,
getHostRiskIndex,
isSubPluginAvailable,
getSubPluginRoutesByCapabilities,
RedirectRoute,
getField,
} from './helpers';
import { StartedSubPlugins } from './types';
@ -274,3 +277,52 @@ describe('RedirectRoute', () => {
`);
});
});
describe('public helpers getField', () => {
it('should return the same value for signal.rule fields as for kibana.alert.rule fields', () => {
const signalRuleName = getField(mockEcsDataWithAlert, 'signal.rule.name');
const aadRuleName = getField(mockEcsDataWithAlert, ALERT_RULE_NAME);
const aadRuleId = getField(mockEcsDataWithAlert, ALERT_RULE_UUID);
const signalRuleId = getField(mockEcsDataWithAlert, 'signal.rule.id');
expect(signalRuleName).toEqual(aadRuleName);
expect(signalRuleId).toEqual(aadRuleId);
});
it('should handle flattened rule parameters correctly', () => {
const mockAlertWithParameters = {
...mockEcsDataWithAlert,
'kibana.alert.rule.parameters': {
description: '24/7',
risk_score: '21',
severity: 'low',
timeline_id: '1234-2136-11ea-9864-ebc8cc1cb8c2',
timeline_title: 'Untitled timeline',
meta: {
from: '1000m',
kibana_siem_app_url: 'https://localhost:5601/app/security',
},
author: [],
false_positives: [],
from: 'now-300s',
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
max_signals: 100,
risk_score_mapping: [],
severity_mapping: [],
threat: [],
to: 'now',
references: ['www.test.co'],
version: '1',
exceptions_list: [],
immutable: false,
type: 'query',
language: 'kuery',
index: ['auditbeat-*'],
query: 'user.name: root or user.name: admin',
filters: [],
},
};
const signalQuery = getField(mockAlertWithParameters, 'signal.rule.query');
const aadQuery = getField(mockAlertWithParameters, `${ALERT_RULE_PARAMETERS}.query`);
expect(signalQuery).toEqual(aadQuery);
});
});

View file

@ -262,14 +262,9 @@ export const getField = (ecsData: Ecs, field: string) => {
const paramsField = parts.slice(0, parts.length - 1).join('.');
const params = get(paramsField, ecsData);
const value = get(parts[parts.length - 1], params);
if (isEmpty(value)) {
return [];
}
return value;
}
const value = get(aadField, ecsData) ?? get(siemSignalsField, ecsData);
if (isEmpty(value)) {
return [];
}
return value;
};