From 61850b11e7ea585630fec1ed981a7715e8b7c1bb Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 25 Jan 2022 01:38:59 -0500 Subject: [PATCH] [Security Solution] Open alerts with an associated template in the template view (#123333) * 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 cef886f073c39a1de24e834f2eddd3a0e0f1390c) --- .../components/alerts_table/actions.test.tsx | 63 ++++- .../components/alerts_table/actions.tsx | 264 ++++++++++-------- .../use_investigate_in_timeline.tsx | 3 +- .../security_solution/public/helpers.test.tsx | 52 ++++ .../security_solution/public/helpers.tsx | 7 +- 5 files changed, 263 insertions(+), 126 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index a1b1a8ede750c..ad95f89c850f6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -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, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index c836adbc19922..b058201166729 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -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((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), }; - } - 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, + } 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, + }, }, - }, - ], - }; + ], + }; + } }; -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((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, + 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), }, - 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)) { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx index db1760d86f624..c1cbe657415a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -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, diff --git a/x-pack/plugins/security_solution/public/helpers.test.tsx b/x-pack/plugins/security_solution/public/helpers.test.tsx index 3475ac7c28f7a..ac6c338ce0f4e 100644 --- a/x-pack/plugins/security_solution/public/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/helpers.test.tsx @@ -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); + }); +}); diff --git a/x-pack/plugins/security_solution/public/helpers.tsx b/x-pack/plugins/security_solution/public/helpers.tsx index 3ab8137acbc53..d7cca7845afb4 100644 --- a/x-pack/plugins/security_solution/public/helpers.tsx +++ b/x-pack/plugins/security_solution/public/helpers.tsx @@ -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; };