[Security Solution] Copies over settings from timeline template (#190511)

## Summary

Handles : https://github.com/elastic/kibana/issues/189992

When user had created a timeline template and attached it to the rule,
the columns were not being copied over from template to the timeline
created from the alert generated by same rule.

This PR fixes that as shown in demo below : 


https://github.com/user-attachments/assets/4237672e-943a-43f9-b160-5449399a5fd8

> [!Caution]
> This PR checks below objects that are needed to be copied over from
template
> - columns
> - data providers
>
> If we think, more things should be copied over, please comment below.

## Test Results


![grafik](https://github.com/user-attachments/assets/ad527eda-a1c2-49f0-bcfe-0ea449c29b34)



### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Jatin Kathuria 2024-08-14 18:27:14 +02:00 committed by GitHub
parent b336eecb2e
commit a13f8d983c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 334 additions and 24 deletions

View file

@ -1085,7 +1085,9 @@ export const sendAlertToTimelineAction = async ({
});
}
}
} catch {
} catch (error) {
/* eslint-disable-next-line no-console */
console.error(error);
updateTimelineIsLoading({ id: TimelineId.active, isLoading: false });
return createTimeline({
from,

View file

@ -5,21 +5,30 @@
* 2.0.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { fireEvent, render } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { of } from 'rxjs';
import { TestProviders } from '../../../../common/mock';
import { KibanaServices, useKibana } from '../../../../common/lib/kibana';
import { useKibana } from '../../../../common/lib/kibana';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { useInvestigateInTimeline } from './use_investigate_in_timeline';
import * as actions from '../actions';
import { coreMock } from '@kbn/core/public/mocks';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { AlertTableContextMenuItem } from '../types';
import React from 'react';
import { EuiPopover, EuiContextMenu } from '@elastic/eui';
import * as timelineActions from '../../../../timelines/store/actions';
import { getTimelineTemplate } from '../../../../timelines/containers/api';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../timelines/containers/api');
jest.mock('../../../../common/lib/apm/use_start_transaction');
jest.mock('../../../../common/hooks/use_app_toasts');
const ecsRowData: Ecs = {
_id: '1',
agent: { type: ['blah'] },
host: { name: ['some host name'] },
kibana: {
alert: {
workflow_status: ['open'],
@ -31,32 +40,207 @@ const ecsRowData: Ecs = {
},
};
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/lib/apm/use_start_transaction');
jest.mock('../../../../common/hooks/use_app_toasts');
jest.mock('../actions');
(KibanaServices.get as jest.Mock).mockReturnValue(coreMock.createStart());
const mockSendAlertToTimeline = jest.spyOn(actions, 'sendAlertToTimelineAction');
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
search: {
searchStrategyClient: jest.fn(),
},
query: jest.fn(),
},
const nonECSRowData: TimelineEventsDetailsItem[] = [
{
category: 'agent',
isObjectArray: false,
field: 'agent.type',
values: ['blah'],
},
});
{
category: 'kibana',
isObjectArray: false,
field: 'kibana.alert.workflow_status',
values: ['open'],
},
{
category: 'kibana',
isObjectArray: false,
field: 'kibana.alert.rule.uuid',
values: ['testId'],
},
{
category: 'host',
isObjectArray: false,
field: 'host.name',
values: ['some host name'],
},
];
const getEcsDataWithRuleTypeAndTimelineTemplate = (ruleType: string, ecsData: Ecs = ecsRowData) => {
return {
...ecsData,
kibana: {
...(ecsData?.kibana ?? {}),
alert: {
...(ecsData.kibana?.alert ?? {}),
rule: {
...(ecsData.kibana?.alert.rule ?? {}),
type: [ruleType],
timeline_id: ['dummyTimelineTemplateId'],
},
},
},
} as Ecs;
};
const getNonEcsDataWithRuleTypeAndTimelineTemplate = (
ruleType: string,
nonEcsData: TimelineEventsDetailsItem[] = nonECSRowData
) => {
return [
...nonEcsData,
{
category: 'kibana',
isObjectArray: false,
field: 'kibana.alert.rule.type',
values: [ruleType],
},
{
category: 'kibana',
isObjectArray: false,
field: 'kibana.alert.rule.timeline_id',
values: ['dummyTimelineTemplateId'],
},
];
};
const mockSendAlertToTimeline = jest.spyOn(actions, 'sendAlertToTimelineAction');
(useAppToasts as jest.Mock).mockReturnValue({
addError: jest.fn(),
});
const mockTimelineTemplateResponse = {
data: {
getOneTimeline: {
savedObjectId: '15bc8185-06ef-4956-b7e7-be8e289b13c2',
version: 'WzIzMzUsMl0=',
columns: [
{
columnHeaderType: 'not-filtered',
id: '@timestamp',
type: 'date',
},
{
columnHeaderType: 'not-filtered',
id: 'host.name',
},
{
columnHeaderType: 'not-filtered',
id: 'user.name',
},
],
dataProviders: [
{
and: [],
enabled: true,
id: 'some-random-id',
name: 'host.name',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.name',
value: '{host.name}',
operator: ':',
},
type: 'template',
},
],
dataViewId: 'security-solution-default',
description: '',
eqlOptions: {
eventCategoryField: 'event.category',
tiebreakerField: '',
timestampField: '@timestamp',
query: '',
size: 100,
},
eventType: 'all',
excludedRowRendererIds: [
'alert',
'alerts',
'auditd',
'auditd_file',
'library',
'netflow',
'plain',
'registry',
'suricata',
'system',
'system_dns',
'system_endgame_process',
'system_file',
'system_fim',
'system_security_event',
'system_socket',
'threat_match',
'zeek',
],
favorite: [],
filters: [],
indexNames: ['.alerts-security.alerts-default', 'auditbeat-*', 'filebeat-*', 'packetbeat-*'],
kqlMode: 'filter',
kqlQuery: {
filterQuery: {
kuery: {
kind: 'kuery',
expression: '*',
},
serializedQuery: '{"query_string":{"query":"*"}}',
},
},
title: 'Named Template',
templateTimelineId: 'c755cda6-8a65-4ec2-b6ff-35a5356de8b9',
templateTimelineVersion: 1,
dateRange: {
start: '2024-08-13T22:00:00.000Z',
end: '2024-08-14T21:59:59.999Z',
},
savedQueryId: null,
created: 1723625359467,
createdBy: 'elastic',
updated: 1723625359988,
updatedBy: 'elastic',
timelineType: 'template',
status: 'active',
sort: [
{
columnId: '@timestamp',
columnType: 'date',
sortDirection: 'desc',
esTypes: ['date'],
},
],
savedSearchId: null,
eventIdToNoteIds: [],
noteIds: [],
notes: [],
pinnedEventIds: [],
pinnedEventsSaveObject: [],
},
},
};
const props = {
ecsRowData,
onInvestigateInTimelineAlertClick: () => {},
onInvestigateInTimelineAlertClick: jest.fn(),
};
const addTimelineSpy = jest.spyOn(timelineActions, 'addTimeline');
const RULE_TYPES_TO_BE_TESTED = [
'query',
'esql',
'eql',
'machine_learning',
/* TODO: Complete test suites for below rule types */
// 'new_terms',
// 'eql',
// 'threshold',
// 'threat_match',
];
const renderContextMenu = (items: AlertTableContextMenuItem[]) => {
const panels = [{ id: 0, items }];
return render(
@ -72,11 +256,28 @@ const renderContextMenu = (items: AlertTableContextMenuItem[]) => {
);
};
describe('use investigate in timeline hook', () => {
describe('useInvestigateInTimeline', () => {
let mockSearchStrategyClient = {
search: jest
.fn()
.mockReturnValue(of({ data: getNonEcsDataWithRuleTypeAndTimelineTemplate('query') })),
};
beforeEach(() => {
(getTimelineTemplate as jest.Mock).mockResolvedValue(mockTimelineTemplateResponse);
// by default we return data for query rule type
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
search: mockSearchStrategyClient,
query: jest.fn(),
},
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('it creates a component and click handler', () => {
test('creates a component and click handler', () => {
const { result } = renderHook(() => useInvestigateInTimeline(props), {
wrapper: TestProviders,
});
@ -98,4 +299,101 @@ describe('use investigate in timeline hook', () => {
expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(1);
});
});
describe('investigate an alert with timeline template', () => {
describe.each(RULE_TYPES_TO_BE_TESTED)('Rule type : %s', (ruleType: string) => {
test('should copy columns over from template', async () => {
mockSearchStrategyClient = {
search: jest
.fn()
.mockReturnValue(of({ data: getNonEcsDataWithRuleTypeAndTimelineTemplate(ruleType) })),
};
const ecsData = getEcsDataWithRuleTypeAndTimelineTemplate(ruleType);
const { result } = renderHook(
() => useInvestigateInTimeline({ ...props, ecsRowData: ecsData }),
{
wrapper: TestProviders,
}
);
const expectedColumns = [
{
columnHeaderType: 'not-filtered',
id: '@timestamp',
type: 'date',
initialWidth: 215,
},
{
columnHeaderType: 'not-filtered',
id: 'host.name',
initialWidth: undefined,
},
{
columnHeaderType: 'not-filtered',
id: 'user.name',
initialWidth: undefined,
},
];
const investigateAction = result.current.investigateInTimelineAlertClick;
await investigateAction();
await waitFor(() => {
expect(addTimelineSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
timeline: expect.objectContaining({
columns: expectedColumns,
}),
})
);
});
});
test('should copy dataProviders over from template', async () => {
mockSearchStrategyClient = {
search: jest
.fn()
.mockReturnValue(of({ data: getNonEcsDataWithRuleTypeAndTimelineTemplate(ruleType) })),
};
const ecsData: Ecs = getEcsDataWithRuleTypeAndTimelineTemplate(ruleType);
const { result } = renderHook(
() => useInvestigateInTimeline({ ...props, ecsRowData: ecsData }),
{
wrapper: TestProviders,
}
);
const expectedDataProvider = [
{
and: [],
enabled: true,
id: 'some-random-id',
name: 'some host name',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.name',
value: 'some host name',
operator: ':',
},
type: 'default',
},
];
const investigateAction = result.current.investigateInTimelineAlertClick;
await investigateAction();
await waitFor(() => {
expect(addTimelineSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
timeline: expect.objectContaining({
dataProviders: expectedDataProvider,
}),
})
);
});
});
});
});
});

View file

@ -18,6 +18,7 @@ import { useApi } from '@kbn/securitysolution-list-hooks';
import type { Filter } from '@kbn/es-query';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { isEmpty } from 'lodash';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers';
import { useKibana } from '../../../../common/lib/kibana';
@ -147,10 +148,19 @@ export const useInvestigateInTimeline = ({
const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineDisabled'
);
const updateTimeline = useUpdateTimeline();
const createTimeline = useCallback(
async ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => {
const newColumns = timeline.columns;
const newColumnsOverride =
!newColumns || isEmpty(newColumns)
? !unifiedComponentsInTimelineDisabled
? defaultUdtHeaders
: defaultHeaders
: newColumns;
await clearActiveTimeline();
updateTimelineIsLoading({ id: TimelineId.active, isLoading: false });
updateTimeline({
@ -160,7 +170,7 @@ export const useInvestigateInTimeline = ({
notes: [],
timeline: {
...timeline,
columns: !unifiedComponentsInTimelineDisabled ? defaultUdtHeaders : defaultHeaders,
columns: newColumnsOverride,
indexNames: timeline.indexNames ?? [],
show: true,
excludedRowRendererIds: