From 8e6d31fe0e6a8763c9b4fd44d1a416100bb1c9a5 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 17 Nov 2020 23:06:47 +0000 Subject: [PATCH] [7.10] [SecuritySolution] override timerange for prebuilt templates (#82468) (#83454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [SecuritySolution] override timerange for prebuilt templates (#82468) * override timerange for prebuilt templates * add unit test * add unit tests * make sure it is template * check timelineType * overwrite prebuilt template's timerange * update mock path * override with relative timerange * Update x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts Co-authored-by: Patryk Kopyciński * review Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Patryk Kopyciński * add limits Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Patryk Kopyciński --- packages/kbn-optimizer/limits.yml | 2 +- .../common/utils/default_date_settings.ts | 4 +- .../open_timeline/__mocks__/index.ts | 420 ++++++++++++++++++ .../components/open_timeline/helpers.test.ts | 408 ++++++++++++++++- .../components/open_timeline/helpers.ts | 36 +- .../timelines/store/timeline/helpers.ts | 13 + .../timelines/store/timeline/reducer.test.ts | 33 ++ .../routes/__mocks__/create_timelines.ts | 217 +++++++++ .../routes/utils/create_timelines.test.ts | 166 +++++++ .../timeline/routes/utils/create_timelines.ts | 8 +- 10 files changed, 1300 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/create_timelines.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.test.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index aa896f3ceddce..1b1b5f6569798 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -68,7 +68,7 @@ pageLoadAssetSize: searchprofiler: 67224 security: 189428 securityOss: 30806 - securitySolution: 622587 + securitySolution: 638231 share: 99205 snapshotRestore: 79176 spaces: 389643 diff --git a/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts b/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts index 148143bb00bea..94545424512bc 100644 --- a/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts +++ b/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts @@ -34,8 +34,8 @@ export type DefaultIntervalSetting = DefaultInterval | null | undefined; // Defaults for if everything fails including dateMath.parse(DEFAULT_FROM) or dateMath.parse(DEFAULT_TO) // These should not really be hit unless we are in an extreme buggy state. -const DEFAULT_FROM_MOMENT = moment().subtract(24, 'hours'); -const DEFAULT_TO_MOMENT = moment(); +export const DEFAULT_FROM_MOMENT = moment().subtract(24, 'hours'); +export const DEFAULT_TO_MOMENT = moment(); /** * Retrieves timeRange settings to populate filters diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts new file mode 100644 index 0000000000000..ce4b0f09e5c95 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts @@ -0,0 +1,420 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; + +export const mockTimeline = { + data: { + getOneTimeline: { + savedObjectId: 'eb2781c0-1df5-11eb-8589-2f13958b79f7', + columns: [ + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'message', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'event.category', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'event.action', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'host.name', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'source.ip', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'destination.ip', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'user.name', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + ], + dataProviders: [], + dateRange: { + start: '2020-11-01T14:30:59.935Z', + end: '2020-11-03T14:31:11.417Z', + __typename: 'DateRangePickerResult', + }, + description: '', + eventType: 'all', + eventIdToNoteIds: [], + excludedRowRendererIds: [], + favorite: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: null, __typename: 'SerializedFilterQueryResult' }, + indexNames: [ + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + '.siem-signals-angelachuang-default', + ], + notes: [], + noteIds: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + status: TimelineStatus.active, + title: 'my timeline', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + __typename: 'SortTimelineResult', + }, + created: 1604497127973, + createdBy: 'elastic', + updated: 1604500278364, + updatedBy: 'elastic', + version: 'WzQ4NSwxXQ==', + __typename: 'TimelineResult', + }, + }, + loading: false, + networkStatus: 7, + stale: false, +}; + +export const mockTemplate = { + data: { + getOneTimeline: { + savedObjectId: '0c70a200-1de0-11eb-885c-6fc13fca1850', + columns: [ + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'signal.rule.description', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'event.action', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'process.name', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'The working directory of the process.', + example: '/home/alice', + indexes: null, + id: 'process.working_directory', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.args', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'process.pid', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Absolute path to the process executable.', + example: '/usr/bin/ssh', + indexes: null, + id: 'process.parent.executable', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments.\n\nMay be filtered to protect sensitive information.', + example: '["ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.parent.args', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Process id.', + example: '4242', + indexes: null, + id: 'process.parent.pid', + name: null, + searchable: null, + type: 'number', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'user', + columnHeaderType: 'not-filtered', + description: 'Short name or login of the user.', + example: 'albert', + indexes: null, + id: 'user.name', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'host', + columnHeaderType: 'not-filtered', + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + example: null, + indexes: null, + id: 'host.name', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + ], + dataProviders: [ + { + id: 'timeline-1-8622010a-61fb-490d-b162-beac9c36a853', + name: '{process.name}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'process.name', + displayField: null, + value: '{process.name}', + displayValue: null, + operator: ':', + __typename: 'QueryMatchResult', + }, + and: [], + __typename: 'DataProviderResult', + }, + { + id: 'timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568', + name: '{event.type}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'event.type', + displayField: null, + value: '{event.type}', + displayValue: null, + operator: ':*', + __typename: 'QueryMatchResult', + }, + and: [], + __typename: 'DataProviderResult', + }, + ], + dateRange: { + start: '2020-10-27T14:22:11.809Z', + end: '2020-11-03T14:22:11.809Z', + __typename: 'DateRangePickerResult', + }, + description: '', + eventType: 'all', + eventIdToNoteIds: [], + excludedRowRendererIds: [], + favorite: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: '', __typename: 'KueryFilterQueryResult' }, + serializedQuery: '', + __typename: 'SerializedKueryQueryResult', + }, + __typename: 'SerializedFilterQueryResult', + }, + indexNames: [], + notes: [], + noteIds: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + status: TimelineStatus.immutable, + title: 'Generic Process Timeline', + timelineType: 'template', + templateTimelineId: 'cd55e52b-7bce-4887-88e2-f1ece4c75447', + templateTimelineVersion: 1, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + __typename: 'SortTimelineResult', + }, + created: 1604413368243, + createdBy: 'angela', + updated: 1604413368243, + updatedBy: 'angela', + version: 'WzQwMywxXQ==', + __typename: 'TimelineResult', + }, + }, + loading: false, + networkStatus: 7, + stale: false, +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index c89114ec77138..921527a0079e3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -3,8 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { cloneDeep, omit } from 'lodash/fp'; +import { cloneDeep, getOr, omit } from 'lodash/fp'; import { Dispatch } from 'redux'; +import ApolloClient from 'apollo-client'; import { mockTimelineResults, @@ -30,6 +31,9 @@ import { isUntitled, omitTypenameInTimeline, dispatchUpdateTimeline, + queryTimelineById, + QueryTimelineById, + formatTimelineResultToModel, } from './helpers'; import { OpenTimelineResult, DispatchUpdateTimeline } from './types'; import { KueryFilterQueryKind } from '../../../common/store/model'; @@ -37,6 +41,10 @@ import { Note } from '../../../common/lib/note'; import moment from 'moment'; import sinon from 'sinon'; import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline'; +import { + mockTimeline as mockSelectedTimeline, + mockTemplate as mockSelectedTemplate, +} from './__mocks__'; jest.mock('../../../common/store/inputs/actions'); jest.mock('../../../common/components/url_state/normalize_time_range.ts'); @@ -49,6 +57,15 @@ jest.mock('uuid', () => { }; }); +jest.mock('../../../common/utils/default_date_settings', () => { + const actual = jest.requireActual('../../../common/utils/default_date_settings'); + return { + ...actual, + DEFAULT_FROM_MOMENT: new Date('2020-10-27T11:37:31.655Z'), + DEFAULT_TO_MOMENT: new Date('2020-10-28T11:37:31.655Z'), + }; +}); + describe('helpers', () => { let mockResults: OpenTimelineResult[]; @@ -903,6 +920,395 @@ describe('helpers', () => { id: 'savedObject-1', }); }); + + test('should override timerange if given an Elastic prebuilt template', () => { + const timeline = { + savedObjectId: 'savedObject-1', + title: 'Awesome Timeline', + version: '1', + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + }; + + const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.template); + expect(newTimeline).toEqual({ + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 190, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + dataProviders: [], + dateRange: { end: '2020-10-28T11:37:31.655Z', start: '2020-10-27T11:37:31.655Z' }, + description: '', + deletedEventIds: [], + eventIdToNoteIds: {}, + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + highlightedDropAndProviderId: '', + historyIds: [], + id: 'savedObject-1', + indexNames: [], + isFavorite: false, + isLive: false, + isSelectAllChecked: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + loadingEventIds: [], + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: 'savedObject-1', + selectedEventIds: {}, + show: false, + showCheckboxes: false, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + status: TimelineStatus.immutable, + title: 'Awesome Timeline', + timelineType: TimelineType.template, + templateTimelineId: null, + templateTimelineVersion: null, + version: '1', + width: 1100, + }); + }); + + test('should not override timerange if given a custom template or timeline', () => { + const timeline = { + savedObjectId: 'savedObject-1', + title: 'Awesome Timeline', + version: '1', + status: TimelineStatus.active, + timelineType: TimelineType.default, + }; + + const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.default); + expect(newTimeline).toEqual({ + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 190, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + dataProviders: [], + dateRange: { end: '2020-07-08T08:20:18.966Z', start: '2020-07-07T08:20:18.966Z' }, + description: '', + deletedEventIds: [], + eventIdToNoteIds: {}, + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + highlightedDropAndProviderId: '', + historyIds: [], + id: 'savedObject-1', + indexNames: [], + isFavorite: false, + isLive: false, + isSelectAllChecked: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + loadingEventIds: [], + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: 'savedObject-1', + selectedEventIds: {}, + show: false, + showCheckboxes: false, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + status: TimelineStatus.active, + title: 'Awesome Timeline', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, + version: '1', + width: 1100, + }); + }); + }); + + describe('queryTimelineById', () => { + describe('open a timeline', () => { + const updateIsLoading = jest.fn(); + const selectedTimeline = { + ...mockSelectedTimeline, + }; + const apolloClient = { + query: (jest.fn().mockResolvedValue(selectedTimeline) as unknown) as ApolloClient<{}>, + }; + const onOpenTimeline = jest.fn(); + const args = { + apolloClient, + duplicate: false, + graphEventId: '', + timelineId: '', + timelineType: TimelineType.default, + onOpenTimeline, + openTimeline: true, + updateIsLoading, + updateTimeline: jest.fn(), + }; + + beforeAll(async () => { + await queryTimelineById<{}>((args as unknown) as QueryTimelineById<{}>); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('dispatch updateIsLoading to true', () => { + expect(updateIsLoading.mock.calls[0][0]).toEqual({ + id: TimelineId.active, + isLoading: true, + }); + }); + + test('get timeline by Id', () => { + expect(apolloClient.query).toHaveBeenCalled(); + }); + + test('Do not override daterange if TimelineStatus is active', () => { + const { timeline } = formatTimelineResultToModel( + omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)), + args.duplicate, + args.timelineType + ); + expect(onOpenTimeline).toBeCalledWith({ + ...timeline, + }); + }); + + test('dispatch updateIsLoading to false', () => { + expect(updateIsLoading.mock.calls[1][0]).toEqual({ + id: TimelineId.active, + isLoading: false, + }); + }); + }); + + describe('update a timeline', () => { + const updateIsLoading = jest.fn(); + const updateTimeline = jest.fn(); + const selectedTimeline = { ...mockSelectedTimeline }; + const apolloClient = { + query: (jest.fn().mockResolvedValue(selectedTimeline) as unknown) as ApolloClient<{}>, + }; + const args = { + apolloClient, + duplicate: false, + graphEventId: '', + timelineId: '', + timelineType: TimelineType.default, + openTimeline: true, + updateIsLoading, + updateTimeline, + }; + + beforeAll(async () => { + await queryTimelineById<{}>((args as unknown) as QueryTimelineById<{}>); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('dispatch updateIsLoading to true', () => { + expect(updateIsLoading.mock.calls[0][0]).toEqual({ + id: TimelineId.active, + isLoading: true, + }); + }); + + test('get timeline by Id', () => { + expect(apolloClient.query).toHaveBeenCalled(); + }); + + test('should not override daterange if TimelineStatus is active', () => { + const { timeline } = formatTimelineResultToModel( + omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)), + args.duplicate, + args.timelineType + ); + expect(updateTimeline).toBeCalledWith({ + timeline: { + ...timeline, + graphEventId: '', + show: true, + dateRange: { + start: '2020-07-07T08:20:18.966Z', + end: '2020-07-08T08:20:18.966Z', + }, + }, + duplicate: false, + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', + notes: [], + id: TimelineId.active, + }); + }); + + test('dispatch updateIsLoading to false', () => { + expect(updateIsLoading.mock.calls[1][0]).toEqual({ + id: TimelineId.active, + isLoading: false, + }); + }); + }); + + describe('open an immutable template', () => { + const updateIsLoading = jest.fn(); + const template = { ...mockSelectedTemplate }; + const apolloClient = { + query: (jest.fn().mockResolvedValue(template) as unknown) as ApolloClient<{}>, + }; + const onOpenTimeline = jest.fn(); + const args = { + apolloClient, + duplicate: false, + graphEventId: '', + timelineId: '', + timelineType: TimelineType.template, + onOpenTimeline, + openTimeline: true, + updateIsLoading, + updateTimeline: jest.fn(), + }; + + beforeAll(async () => { + await queryTimelineById<{}>((args as unknown) as QueryTimelineById<{}>); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('dispatch updateIsLoading to true', () => { + expect(updateIsLoading.mock.calls[0][0]).toEqual({ + id: TimelineId.active, + isLoading: true, + }); + }); + + test('get timeline by Id', () => { + expect(apolloClient.query).toHaveBeenCalled(); + }); + + test('override daterange if TimelineStatus is immutable', () => { + const { timeline } = formatTimelineResultToModel( + omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', template)), + args.duplicate, + args.timelineType + ); + expect(onOpenTimeline).toBeCalledWith({ + ...timeline, + dateRange: { + end: '2020-10-28T11:37:31.655Z', + start: '2020-10-27T11:37:31.655Z', + }, + }); + }); + + test('dispatch updateIsLoading to false', () => { + expect(updateIsLoading.mock.calls[1][0]).toEqual({ + id: TimelineId.active, + isLoading: false, + }); + }); + }); }); describe('omitTypenameInTimeline', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 4c3be81a4992a..a0090baeb9923 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -33,7 +33,10 @@ import { addNotes as dispatchAddNotes, updateNote as dispatchUpdateNote, } from '../../../common/store/app/actions'; -import { setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker } from '../../../common/store/inputs/actions'; +import { + setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker, + setRelativeRangeDatePicker as dispatchSetRelativeRangeDatePicker, +} from '../../../common/store/inputs/actions'; import { setKqlFilterQueryDraft as dispatchSetKqlFilterQueryDraft, applyKqlFilterQuery as dispatchApplyKqlFilterQuery, @@ -58,6 +61,10 @@ import { IS_OPERATOR } from '../timeline/data_providers/data_provider'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; import { sourcererActions } from '../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { + DEFAULT_FROM_MOMENT, + DEFAULT_TO_MOMENT, +} from '../../../common/utils/default_date_settings'; export const OPEN_TIMELINE_CLASS_NAME = 'open-timeline'; @@ -252,6 +259,14 @@ export const defaultTimelineToTimelineModel = ( const timelineEntries = { ...timeline, columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders, + dateRange: + timeline.status === TimelineStatus.immutable && + timeline.timelineType === TimelineType.template + ? { + start: DEFAULT_FROM_MOMENT.toISOString(), + end: DEFAULT_TO_MOMENT.toISOString(), + } + : timeline.dateRange, dataProviders: getDataProviders(duplicate, timeline.dataProviders, timelineType), eventIdToNoteIds: setEventIdToNoteIds(duplicate, timeline.eventIdToNoteIds), filters: timeline.filters != null ? timeline.filters.map(setTimelineFilters) : [], @@ -340,6 +355,7 @@ export const queryTimelineById = ({ duplicate, timelineType ); + if (onOpenTimeline != null) { onOpenTimeline(timeline); } else if (updateTimeline) { @@ -356,6 +372,7 @@ export const queryTimelineById = ({ ...timeline, graphEventId, show: openTimeline, + dateRange: { start: from, end: to }, }, to, })(); @@ -384,7 +401,22 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli eventType: timeline.eventType, }) ); - dispatch(dispatchSetTimelineRangeDatePicker({ from, to })); + if ( + timeline.status === TimelineStatus.immutable && + timeline.timelineType === TimelineType.template + ) { + dispatch( + dispatchSetRelativeRangeDatePicker({ + id: 'timeline', + fromStr: 'now-24h', + toStr: 'now', + from: DEFAULT_FROM_MOMENT.toISOString(), + to: DEFAULT_TO_MOMENT.toISOString(), + }) + ); + } else { + dispatch(dispatchSetTimelineRangeDatePicker({ from, to })); + } dispatch(dispatchAddTimeline({ id, timeline, savedTimeline: duplicate })); if ( timeline.kqlQuery != null && diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index d4e807b4a9a07..9a0bf5ec4a940 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -26,6 +26,7 @@ import { TimelineTypeLiteral, TimelineType, RowRendererId, + TimelineStatus, TimelineId, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; @@ -33,6 +34,10 @@ import { normalizeTimeRange } from '../../../common/components/url_state/normali import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel } from './model'; import { TimelineById } from './types'; +import { + DEFAULT_FROM_MOMENT, + DEFAULT_TO_MOMENT, +} from '../../../common/utils/default_date_settings'; import { activeTimeline } from '../../containers/active_timeline_context'; export const isNotNull = (value: T | null): value is T => value !== null; @@ -144,6 +149,14 @@ export const addTimelineToStore = ({ [id]: { ...timeline, isLoading: timelineById[id].isLoading, + dateRange: + timeline.status === TimelineStatus.immutable && + timeline.timelineType === TimelineType.template + ? { + start: DEFAULT_FROM_MOMENT.toISOString(), + end: DEFAULT_TO_MOMENT.toISOString(), + } + : timeline.dateRange, }, }; }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index c2f43625ab464..7bd86cd7e2452 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -48,6 +48,14 @@ import { timelineDefaults } from './defaults'; import { TimelineById } from './types'; jest.mock('../../../common/components/url_state/normalize_time_range.ts'); +jest.mock('../../../common/utils/default_date_settings', () => { + const actual = jest.requireActual('../../../common/utils/default_date_settings'); + return { + ...actual, + DEFAULT_FROM_MOMENT: new Date('2020-10-27T11:37:31.655Z'), + DEFAULT_TO_MOMENT: new Date('2020-10-28T11:37:31.655Z'), + }; +}); const basicDataProvider: DataProvider = { and: [], @@ -141,6 +149,31 @@ describe('Timeline', () => { }, }); }); + + test('should override timerange if adding an immutable template', () => { + const update = addTimelineToStore({ + id: 'foo', + timeline: { + ...basicTimeline, + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + }, + timelineById: timelineByIdMock, + }); + + expect(update).toEqual({ + foo: { + ...basicTimeline, + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + dateRange: { + start: '2020-10-27T11:37:31.655Z', + end: '2020-10-28T11:37:31.655Z', + }, + show: true, + }, + }); + }); }); describe('#addNewTimeline', () => { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/create_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/create_timelines.ts new file mode 100644 index 0000000000000..e8242d9691032 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/create_timelines.ts @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockTemplate = { + columns: [ + { + columnHeaderType: 'not-filtered', + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'signal.rule.description', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.action', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'process.name', + name: null, + searchable: null, + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'The working directory of the process.', + example: '/home/alice', + indexes: null, + id: 'process.working_directory', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.args', + name: null, + searchable: null, + type: 'string', + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'process.pid', + name: null, + searchable: null, + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Absolute path to the process executable.', + example: '/usr/bin/ssh', + indexes: null, + id: 'process.parent.executable', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments.\n\nMay be filtered to protect sensitive information.', + example: '["ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.parent.args', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Process id.', + example: '4242', + indexes: null, + id: 'process.parent.pid', + name: null, + searchable: null, + type: 'number', + }, + { + aggregatable: true, + category: 'user', + columnHeaderType: 'not-filtered', + description: 'Short name or login of the user.', + example: 'albert', + indexes: null, + id: 'user.name', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'host', + columnHeaderType: 'not-filtered', + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + indexes: null, + id: 'host.name', + name: null, + searchable: null, + type: 'string', + }, + ], + dataProviders: [ + { + id: 'timeline-1-8622010a-61fb-490d-b162-beac9c36a853', + name: '{process.name}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'process.name', + displayField: null, + value: '{process.name}', + displayValue: null, + operator: ':', + }, + and: [], + }, + { + id: 'timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568', + name: '{event.type}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'event.type', + displayField: null, + value: '{event.type}', + displayValue: null, + operator: ':*', + }, + and: [], + }, + ], + description: '', + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: { kuery: { kind: 'kuery', expression: '' }, serializedQuery: '' } }, + indexNames: [], + title: 'Generic Process Timeline - Duplicate - Duplicate', + timelineType: 'template', + templateTimelineVersion: null, + templateTimelineId: null, + dateRange: { start: '2020-10-01T11:37:31.655Z', end: '2020-10-02T11:37:31.655Z' }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'active', +}; + +export const mockTimeline = { + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp' }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'event.category' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + ], + dataProviders: [], + description: '', + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: null }, + indexNames: [ + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + '.siem-signals-angelachuang-default', + ], + title: 'my timeline', + timelineType: 'default', + templateTimelineVersion: null, + templateTimelineId: null, + dateRange: { start: '2020-11-03T13:34:40.339Z', end: '2020-11-04T13:34:40.339Z' }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'draft', +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.test.ts new file mode 100644 index 0000000000000..933e71cc10255 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as module from './create_timelines'; +import { persistTimeline } from '../../saved_object'; +import { persistPinnedEventOnTimeline } from '../../../pinned_event/saved_object'; +import { persistNote, getNote } from '../../../note/saved_object'; +import { FrameworkRequest } from '../../../framework'; +import { SavedTimeline } from '../../../../../common/types/timeline'; +import { mockTemplate, mockTimeline } from '../__mocks__/create_timelines'; + +const frameworkRequest = {} as FrameworkRequest; +const template = { ...mockTemplate } as SavedTimeline; +const timeline = { ...mockTimeline } as SavedTimeline; +const timelineSavedObjectId = null; +const timelineVersion = null; +const pinnedEventIds = ['123']; +const notes = [ + { noteId: 'abc', note: 'new note', timelineId: '', created: 1603885051655, createdBy: 'elastic' }, +]; +const existingNoteIds = undefined; +const isImmutable = true; +const newTimelineSavedObjectId = 'eb2781c0-1df5-11eb-8589-2f13958b79f7'; + +jest.mock('moment', () => { + const mockMoment = { + toISOString: jest + .fn() + .mockReturnValueOnce('2020-11-03T11:37:31.655Z') + .mockReturnValue('2020-11-04T11:37:31.655Z'), + subtract: jest.fn(), + }; + mockMoment.subtract.mockReturnValue(mockMoment); + return jest.fn().mockReturnValue(mockMoment); +}); + +jest.mock('../../saved_object', () => ({ + persistTimeline: jest.fn().mockResolvedValue({ + timeline: { + savedObjectId: 'eb2781c0-1df5-11eb-8589-2f13958b79f7', + version: 'xJs23==', + }, + }), +})); + +jest.mock('../../../pinned_event/saved_object', () => ({ + persistPinnedEventOnTimeline: jest.fn(), +})); + +jest.mock('../../../note/saved_object', () => ({ + getNote: jest.fn(), + persistNote: jest.fn(), +})); + +describe('createTimelines', () => { + describe('create timelines', () => { + beforeAll(async () => { + await module.createTimelines({ + frameworkRequest, + timeline, + timelineSavedObjectId, + timelineVersion, + pinnedEventIds, + notes, + existingNoteIds, + isImmutable: false, + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('respect input timerange - start', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.start).toEqual( + '2020-11-03T13:34:40.339Z' + ); + }); + + test('respect input timerange - end', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.end).toEqual( + '2020-11-04T13:34:40.339Z' + ); + }); + + test('savePinnedEvents', () => { + expect((persistPinnedEventOnTimeline as jest.Mock).mock.calls[0][2]).toEqual('123'); + }); + + test('saveNotes', () => { + expect((persistNote as jest.Mock).mock.calls[0][3]).toEqual({ + eventId: undefined, + note: 'new note', + timelineId: newTimelineSavedObjectId, + }); + }); + }); + + describe('create immutable templates', () => { + beforeAll(async () => { + (getNote as jest.Mock).mockReturnValue({ + ...notes[0], + }); + await module.createTimelines({ + frameworkRequest, + timeline: template, + timelineSavedObjectId, + timelineVersion, + pinnedEventIds, + notes, + existingNoteIds, + isImmutable, + overrideNotesOwner: false, + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + test('override timerange - start', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.start).toEqual( + '2020-11-03T11:37:31.655Z' + ); + }); + + test('override timerange - end', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.end).toEqual( + '2020-11-04T11:37:31.655Z' + ); + }); + }); + + describe('create custom templates', () => { + beforeAll(async () => { + await module.createTimelines({ + frameworkRequest, + timeline: template, + timelineSavedObjectId, + timelineVersion, + pinnedEventIds, + notes, + existingNoteIds, + isImmutable: false, + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('respect input timerange - start', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.start).toEqual( + '2020-10-01T11:37:31.655Z' + ); + }); + + test('respect input timerange - end', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.end).toEqual( + '2020-10-02T11:37:31.655Z' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts index dc0caaf67d738..83f97ddb01eaa 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts @@ -5,6 +5,7 @@ */ import { isEmpty } from 'lodash/fp'; +import moment from 'moment'; import * as timelineLib from '../../saved_object'; import * as pinnedEventLib from '../../../pinned_event/saved_object'; import * as noteLib from '../../../note/saved_object'; @@ -128,15 +129,20 @@ export const createTimelines = async ({ isImmutable, overrideNotesOwner = true, }: CreateTimelineProps): Promise => { + const timerangeStart = isImmutable + ? moment().subtract(24, 'hours').toISOString() + : timeline.dateRange?.start; + const timerangeEnd = isImmutable ? moment().toISOString() : timeline.dateRange?.end; const responseTimeline = await saveTimelines( frameworkRequest, - timeline, + { ...timeline, dateRange: { start: timerangeStart, end: timerangeEnd } }, timelineSavedObjectId, timelineVersion, isImmutable ); const newTimelineSavedObjectId = responseTimeline.timeline.savedObjectId; const newTimelineVersion = responseTimeline.timeline.version; + let myPromises: unknown[] = []; if (pinnedEventIds != null && !isEmpty(pinnedEventIds)) { myPromises = [