From 384ff4718b1280b43baf527ede3657a25a586d74 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 26 Jun 2020 15:36:51 -0600 Subject: [PATCH] [Security] `Investigate in Resolver` Timeline Integration (#70111) ## [Security] `Investigate in Resolver` Timeline Integration This PR adds a new `Investigate in Resolver` action to the Timeline, and all timeline-based views, including: - Timeline - Alert list (i.e. Signals) - Hosts > Events - Hosts > External alerts - Network > External alerts ![investigate-in-resolver-action](https://user-images.githubusercontent.com/4459398/85886173-c40d1c80-b7a2-11ea-8011-0221fef95d51.png) ### Resolver Overlay When the `Investigate in Resolver` action is clicked, Resolver is displayed in an overlay over the events. The screenshot below has placeholder text where Resolver will be rendered: ![resolver-overlay](https://user-images.githubusercontent.com/4459398/85886309-10f0f300-b7a3-11ea-95cb-0117207e4890.png) The Resolver overlay is closed by clicking the `< Back to events` button shown in the screenshot above. The state of the timeline is restored when the overlay is closed. The scroll position (within the events), any expanded events, etc, will appear exactly as they were before the Resolver overlay was displayed. ### Case Integration Users may link directly to a Timeline Resolver view from cases via the `Attach to new case` and `Attach to existing case...` actions show in the screenshot below: ![case-integration](https://user-images.githubusercontent.com/4459398/85886773-e3587980-b7a3-11ea-87b6-b098ea14bc5f.png) ![investigate-in-resolver](https://user-images.githubusercontent.com/4459398/85885618-daff3f00-b7a1-11ea-9356-2e8a1291f213.gif) When users click the link in a case, Timeline will automatically open to the Resolver view in the link. ### URL State Users can directly share Resolver views (in saved Timelines) with other users by copying the Kibana URL to the clipboard when Resolver is open. When another user pastes the URL in their browser, Timeline will automatically open and display the Resolver view in the URL. ### Enabling the `Investigate in Resolver` action In this PR, the `Investigate in Resolver` action is only enabled for events where all of the following are true: - `agent.type` is `endpoint` - `process.entity_id` exists ### Context passed to Resolver The only context passed to `Resolver` is the `_id` of the event (when the user clicks `Investigate in Resolver`) ### What's next? - @oatkiller will replace the placeholder text shown in the screenshots above with the actual call to Resolver in a separate PR - I will follow-up this PR with additional tests - The action text `Investigate in Resolver` may be changed in a future PR - Hide the `Add to case` action in timeline-based views (it's currently visible, but disabled) --- .../alerts_table/default_config.tsx | 24 +- .../components/alerts_table/index.test.tsx | 53 +- .../alerts/components/alerts_table/index.tsx | 7 +- .../components/alerts_viewer/alerts_table.tsx | 10 +- .../events_viewer/events_viewer.tsx | 14 +- .../navigation/breadcrumbs/index.test.ts | 1 + .../components/navigation/index.test.tsx | 4 +- .../navigation/tab_navigation/index.test.tsx | 2 + .../common/components/url_state/helpers.ts | 3 +- .../url_state/initialize_redux_by_url.tsx | 1 + .../public/graphql/introspection.json | 35 + .../security_solution/public/graphql/types.ts | 18 + .../fields_browser/categories_pane.tsx | 6 +- .../fields_browser/field_browser.tsx | 6 +- .../components/fields_browser/index.tsx | 1 + .../components/flyout/header/index.tsx | 6 +- .../components/graph_overlay/index.tsx | 150 ++ .../components/graph_overlay/translations.ts | 14 + .../components/open_timeline/helpers.ts | 3 + .../__snapshots__/timeline.test.tsx.snap | 1778 +++++++++-------- .../timeline/body/actions/index.tsx | 40 +- .../__snapshots__/index.test.tsx.snap | 4 +- .../timeline/body/column_headers/index.tsx | 35 +- .../components/timeline/body/constants.ts | 6 +- .../body/events/event_column_view.tsx | 17 +- .../components/timeline/body/helpers.ts | 36 +- .../components/timeline/body/index.test.tsx | 6 + .../components/timeline/body/index.tsx | 19 +- .../timeline/body/stateful_body.tsx | 13 +- .../components/timeline/body/translations.ts | 7 + .../components/timeline/header/index.tsx | 41 +- .../timelines/components/timeline/helpers.tsx | 2 + .../components/timeline/index.test.tsx | 1 + .../timelines/components/timeline/index.tsx | 5 + .../insert_timeline_popover/index.test.tsx | 6 +- .../insert_timeline_popover/index.tsx | 12 +- .../use_insert_timeline.tsx | 21 +- .../timeline/properties/helpers.test.tsx | 1 + .../timeline/properties/helpers.tsx | 49 +- .../timeline/properties/index.test.tsx | 12 +- .../components/timeline/properties/index.tsx | 14 +- .../timeline/properties/properties_right.tsx | 3 + .../timeline/properties/translations.ts | 16 +- .../timeline/selectable_timeline/index.tsx | 9 +- .../timelines/components/timeline/styles.tsx | 23 +- .../components/timeline/timeline.test.tsx | 6 +- .../components/timeline/timeline.tsx | 11 + .../timelines/containers/index.gql_query.ts | 4 + .../timelines/store/timeline/actions.ts | 4 + .../timelines/store/timeline/helpers.ts | 20 + .../public/timelines/store/timeline/model.ts | 4 + .../timelines/store/timeline/reducer.test.ts | 2 +- .../timelines/store/timeline/reducer.ts | 6 + .../public/timelines/store/timeline/types.ts | 1 + .../server/graphql/ecs/schema.gql.ts | 6 + .../security_solution/server/graphql/types.ts | 35 + .../server/lib/ecs_fields/index.ts | 6 + 57 files changed, 1615 insertions(+), 1024 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx index 2029c5169c2cdc..6d82897aaf0102 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx @@ -8,6 +8,7 @@ import React from 'react'; import ApolloClient from 'apollo-client'; +import { Dispatch } from 'redux'; import { EuiText } from '@elastic/eui'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -17,10 +18,12 @@ import { TimelineRowActionOnClick, } from '../../../timelines/components/timeline/body/actions'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; +import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, } from '../../../timelines/components/timeline/body/constants'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../timelines/components/timeline/helpers'; import { ColumnHeaderOptions, SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; @@ -174,23 +177,27 @@ export const getAlertActions = ({ apolloClient, canUserCRUD, createTimeline, + dispatch, hasIndexWrite, onAlertStatusUpdateFailure, onAlertStatusUpdateSuccess, setEventsDeleted, setEventsLoading, status, + timelineId, updateTimelineIsLoading, }: { apolloClient?: ApolloClient<{}>; canUserCRUD: boolean; createTimeline: CreateTimeline; + dispatch: Dispatch; hasIndexWrite: boolean; onAlertStatusUpdateFailure: (status: Status, error: Error) => void; onAlertStatusUpdateSuccess: (count: number, status: Status) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; status: Status; + timelineId: string; updateTimelineIsLoading: UpdateTimelineLoading; }): TimelineRowAction[] => { const openAlertActionComponent: TimelineRowAction = { @@ -199,7 +206,7 @@ export const getAlertActions = ({ dataTestSubj: 'open-alert-status', displayType: 'contextMenu', id: FILTER_OPEN, - isActionDisabled: !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, onClick: ({ eventId }: TimelineRowActionOnClick) => updateAlertStatusAction({ alertIds: [eventId], @@ -210,7 +217,7 @@ export const getAlertActions = ({ status, selectedStatus: FILTER_OPEN, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }; const closeAlertActionComponent: TimelineRowAction = { @@ -219,7 +226,7 @@ export const getAlertActions = ({ dataTestSubj: 'close-alert-status', displayType: 'contextMenu', id: FILTER_CLOSED, - isActionDisabled: !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, onClick: ({ eventId }: TimelineRowActionOnClick) => updateAlertStatusAction({ alertIds: [eventId], @@ -230,7 +237,7 @@ export const getAlertActions = ({ status, selectedStatus: FILTER_CLOSED, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }; const inProgressAlertActionComponent: TimelineRowAction = { @@ -239,7 +246,7 @@ export const getAlertActions = ({ dataTestSubj: 'in-progress-alert-status', displayType: 'contextMenu', id: FILTER_IN_PROGRESS, - isActionDisabled: !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, onClick: ({ eventId }: TimelineRowActionOnClick) => updateAlertStatusAction({ alertIds: [eventId], @@ -250,10 +257,13 @@ export const getAlertActions = ({ status, selectedStatus: FILTER_IN_PROGRESS, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }; return [ + { + ...getInvestigateInResolverAction({ dispatch, timelineId }), + }, { ariaLabel: 'Send alert to timeline', content: i18n.ACTION_INVESTIGATE_IN_TIMELINE, @@ -268,7 +278,7 @@ export const getAlertActions = ({ ecsData, updateTimelineIsLoading, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }, // Context menu items ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx index f843bf68818465..9ff368aff2bf68 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx @@ -7,37 +7,40 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { TestProviders } from '../../../common/mock/test_providers'; import { TimelineId } from '../../../../common/types/timeline'; import { AlertsTableComponent } from './index'; describe('AlertsTableComponent', () => { it('renders correctly', () => { const wrapper = shallow( - + + + ); expect(wrapper.find('[title="Alerts"]')).toBeTruthy(); diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx index ba6102312fef67..ec088c111e3bbc 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx @@ -7,7 +7,7 @@ import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -84,6 +84,7 @@ export const AlertsTableComponent: React.FC = ({ updateTimeline, updateTimelineIsLoading, }) => { + const dispatch = useDispatch(); const [selectAll, setSelectAll] = useState(false); const apolloClient = useApolloClient(); @@ -292,11 +293,13 @@ export const AlertsTableComponent: React.FC = ({ getAlertActions({ apolloClient, canUserCRUD, + dispatch, hasIndexWrite, createTimeline: createTimelineCallback, setEventsLoading: setEventsLoadingCallback, setEventsDeleted: setEventsDeletedCallback, status: filterGroup, + timelineId, updateTimelineIsLoading, onAlertStatusUpdateSuccess, onAlertStatusUpdateFailure, @@ -305,10 +308,12 @@ export const AlertsTableComponent: React.FC = ({ apolloClient, canUserCRUD, createTimelineCallback, + dispatch, hasIndexWrite, filterGroup, setEventsLoadingCallback, setEventsDeletedCallback, + timelineId, updateTimelineIsLoading, onAlertStatusUpdateSuccess, onAlertStatusUpdateFailure, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index 251e0278b11bab..6d5471404ab4d1 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -5,13 +5,16 @@ */ import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { TimelineIdLiteral } from '../../../../common/types/timeline'; import { StatefulEventsViewer } from '../events_viewer'; import { alertsDefaultModel } from './default_headers'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import * as i18n from './translations'; + export interface OwnProps { end: number; id: string; @@ -64,8 +67,9 @@ const AlertsTableComponent: React.FC = ({ startDate, pageFilters = [], }) => { + const dispatch = useDispatch(); const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]); - const { initializeTimeline } = useManageTimeline(); + const { initializeTimeline, setTimelineRowActions } = useManageTimeline(); useEffect(() => { initializeTimeline({ @@ -75,6 +79,10 @@ const AlertsTableComponent: React.FC = ({ title: i18n.ALERTS_TABLE_TITLE, unit: i18n.UNIT, }); + setTimelineRowActions({ + id: timelineId, + timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId })], + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 6b4baac0ff26c4..9e38b14c4334a5 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -7,6 +7,7 @@ import { EuiPanel } from '@elastic/eui'; import { getOr, isEmpty, union } from 'lodash/fp'; import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -34,6 +35,7 @@ import { } from '../../../../../../../src/plugins/data/public'; import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; const DEFAULT_EVENTS_VIEWER_HEIGHT = 500; @@ -91,6 +93,7 @@ const EventsViewerComponent: React.FC = ({ toggleColumn, utilityBar, }) => { + const dispatch = useDispatch(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const { filterManager } = useKibana().services.data.query; @@ -100,7 +103,16 @@ const EventsViewerComponent: React.FC = ({ getManageTimelineById, setIsTimelineLoading, setTimelineFilterManager, + setTimelineRowActions, } = useManageTimeline(); + + useEffect(() => { + setTimelineRowActions({ + id, + timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId: id })], + }); + }, [setTimelineRowActions, id, dispatch]); + useEffect(() => { setIsTimelineLoading({ id, isLoading: isQueryLoading }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -179,9 +191,7 @@ const EventsViewerComponent: React.FC = ({ {headerFilterGroup} - {utilityBar?.(refetch, totalCountMinusDeleted)} - { [CONSTANTS.timeline]: { id: '', isOpen: false, + graphEventId: '', }, }, }; @@ -160,6 +161,7 @@ describe('SIEM Navigation', () => { timeline: { id: '', isOpen: false, + graphEventId: '', }, timerange: { global: { @@ -266,7 +268,7 @@ describe('SIEM Navigation', () => { search: '', state: undefined, tabName: 'authentications', - timeline: { id: '', isOpen: false }, + timeline: { id: '', isOpen: false, graphEventId: '' }, timerange: { global: { linkTo: ['timeline'], diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx index 977c7808b6c86e..f345346d620cb1 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx @@ -71,6 +71,7 @@ describe('Tab Navigation', () => { [CONSTANTS.timeline]: { id: '', isOpen: false, + graphEventId: '', }, }; test('it mounts with correct tab highlighted', () => { @@ -128,6 +129,7 @@ describe('Tab Navigation', () => { [CONSTANTS.timeline]: { id: '', isOpen: false, + graphEventId: '', }, }; test('it mounts with correct tab highlighted', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index c270a99d3c51e5..7f4267bc5e2b34 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -126,8 +126,9 @@ export const makeMapStateToProps = () => { ? { id: flyoutTimeline.savedObjectId != null ? flyoutTimeline.savedObjectId : '', isOpen: flyoutTimeline.show, + graphEventId: flyoutTimeline.graphEventId ?? '', } - : { id: '', isOpen: false }; + : { id: '', isOpen: false, graphEventId: '' }; let searchAttr: { [CONSTANTS.appQuery]?: Query; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx index efd6221bbfbd02..ab03e2199474c6 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx @@ -81,6 +81,7 @@ export const dispatchSetInitialStateFromUrl = ( queryTimelineById({ apolloClient, duplicate: false, + graphEventId: timeline.graphEventId, timelineId: timeline.id, openTimeline: timeline.isOpen, updateIsLoading: updateTimelineIsLoading, diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 3c8c7c21d72a02..48547212bb6c01 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -3570,6 +3570,14 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "agent", + "description": "", + "args": [], + "type": { "kind": "OBJECT", "name": "AgentEcsField", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "auditd", "description": "", @@ -3760,6 +3768,25 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AgentEcsField", + "description": "", + "fields": [ + { + "name": "type", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "AuditdEcsFields", @@ -5728,6 +5755,14 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "entity_id", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "executable", "description": "", diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index dc4a8ae78bf46d..b5088fe51b446b 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -763,6 +763,8 @@ export interface Ecs { _index?: Maybe; + agent?: Maybe; + auditd?: Maybe; destination?: Maybe; @@ -810,6 +812,10 @@ export interface Ecs { system?: Maybe; } +export interface AgentEcsField { + type?: Maybe; +} + export interface AuditdEcsFields { result?: Maybe; @@ -1265,6 +1271,8 @@ export interface ProcessEcsFields { args?: Maybe; + entity_id?: Maybe; + executable?: Maybe; title?: Maybe; @@ -4605,6 +4613,8 @@ export namespace GetTimelineQuery { event: Maybe; + agent: Maybe; + auditd: Maybe; file: Maybe; @@ -4730,6 +4740,12 @@ export namespace GetTimelineQuery { type: Maybe; }; + export type Agent = { + __typename?: 'AgentEcsField'; + + type: Maybe; + }; + export type Auditd = { __typename?: 'AuditdEcsFields'; @@ -5155,6 +5171,8 @@ export namespace GetTimelineQuery { args: Maybe; + entity_id: Maybe; + executable: Maybe; title: Maybe; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx index 480070fda9594e..7addfaaf7c5fce 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx @@ -32,6 +32,10 @@ const Title = styled(EuiTitle)` padding-left: 5px; `; +const H5 = styled.h5` + text-align: left; +`; + Title.displayName = 'Title'; type Props = Pick & { @@ -64,7 +68,7 @@ export const CategoriesPane = React.memo( }) => ( <> - <h5 data-test-subj="categories-pane-title">{i18n.CATEGORIES}</h5> + <H5 data-test-subj="categories-pane-title">{i18n.CATEGORIES}</H5> ` border: ${({ theme }) => theme.eui.euiBorderWidthThin} solid ${({ theme }) => theme.eui.euiColorMediumShade}; border-radius: ${({ theme }) => theme.eui.euiBorderRadius}; - left: 0; + left: 8px; padding: ${({ theme }) => theme.eui.paddingSizes.s} ${({ theme }) => theme.eui.paddingSizes.s} - ${({ theme }) => theme.eui.paddingSizes.m}; + ${({ theme }) => theme.eui.paddingSizes.s}; position: absolute; - top: calc(100% + ${({ theme }) => theme.eui.euiSize}); + top: calc(100% + 4px); width: ${({ width }) => width}px; z-index: 9990; `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx index a3e93ff3c90eb9..a3937107936b68 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx @@ -26,6 +26,7 @@ export const INPUT_TIMEOUT = 250; const FieldsBrowserButtonContainer = styled.div` position: relative; + width: 24px; `; FieldsBrowserButtonContainer.displayName = 'FieldsBrowserButtonContainer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 8ad32d6e2cad01..9fe48cd2f0190b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -33,6 +33,7 @@ const StatefulFlyoutHeader = React.memo( associateNote, createTimeline, description, + graphEventId, isDataInTimeline, isDatepickerLocked, isFavorite, @@ -58,6 +59,7 @@ const StatefulFlyoutHeader = React.memo( createTimeline={createTimeline} description={description} getNotesByIds={getNotesByIds} + graphEventId={graphEventId} isDataInTimeline={isDataInTimeline} isDatepickerLocked={isDatepickerLocked} isFavorite={isFavorite} @@ -92,6 +94,7 @@ const makeMapStateToProps = () => { const { dataProviders, description = '', + graphEventId, isFavorite = false, kqlQuery, title = '', @@ -103,13 +106,14 @@ const makeMapStateToProps = () => { return { description, - notesById: getNotesByIds(state), + graphEventId, history, isDataInTimeline: !isEmpty(dataProviders) || !isEmpty(get('filterQuery.kuery.expression', kqlQuery)), isFavorite, isDatepickerLocked: globalInput.linkTo.includes('timeline'), noteIds, + notesById: getNotesByIds(state), status, title, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx new file mode 100644 index 00000000000000..fe38dd79176a5c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -0,0 +1,150 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiTitle, +} from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React, { useCallback, useState } from 'react'; +import { connect, ConnectedProps, useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; + +import { SecurityPageName } from '../../../app/types'; +import { AllCasesModal } from '../../../cases/components/all_cases_modal'; +import { getCaseDetailsUrl } from '../../../common/components/link_to'; +import { APP_ID } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; +import { State } from '../../../common/store'; +import { timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; +import { TimelineModel } from '../../store/timeline/model'; +import { NewCase, ExistingCase } from '../timeline/properties/helpers'; +import { UNTITLED_TIMELINE } from '../timeline/properties/translations'; +import { + setInsertTimeline, + updateTimelineGraphEventId, +} from '../../../timelines/store/timeline/actions'; + +import * as i18n from './translations'; + +const OverlayContainer = styled.div<{ bodyHeight?: number }>` + height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; + width: 100%; +`; + +interface OwnProps { + bodyHeight?: number; + graphEventId?: string; + timelineId: string; +} + +const GraphOverlayComponent = ({ + bodyHeight, + graphEventId, + status, + timelineId, + title, +}: OwnProps & PropsFromRedux) => { + const dispatch = useDispatch(); + const { navigateToApp } = useKibana().services.application; + const onCloseOverlay = useCallback(() => { + dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' })); + }, [dispatch, timelineId]); + const [showCaseModal, setShowCaseModal] = useState(false); + const onOpenCaseModal = useCallback(() => setShowCaseModal(true), []); + const onCloseCaseModal = useCallback(() => setShowCaseModal(false), [setShowCaseModal]); + const currentTimeline = useSelector((state: State) => + timelineSelectors.selectTimeline(state, timelineId) + ); + const onRowClick = useCallback( + (id: string) => { + onCloseCaseModal(); + + dispatch( + setInsertTimeline({ + graphEventId, + timelineId, + timelineSavedObjectId: currentTimeline.savedObjectId, + timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE, + }) + ); + + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id }), + }); + }, + [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] + ); + + return ( + + + + + + {i18n.BACK_TO_EVENTS} + + + + + + + + + + + + + + + + + <>{`Resolver graph for event _id ${graphEventId}`} + + + + ); +}; + +const makeMapStateToProps = () => { + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const mapStateToProps = (state: State, { timelineId }: OwnProps) => { + const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; + const { status, title = '' } = timeline; + + return { + status, + title, + }; + }; + return mapStateToProps; +}; + +const connector = connect(makeMapStateToProps); + +type PropsFromRedux = ConnectedProps; + +export const GraphOverlay = connector(GraphOverlayComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts new file mode 100644 index 00000000000000..c7cd9253de0383 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts @@ -0,0 +1,14 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const BACK_TO_EVENTS = i18n.translate( + 'xpack.securitySolution.timeline.graphOverlay.backToEventsButton', + { + defaultMessage: '< Back to events', + } +); 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 c8a47798f169ce..520215cde4862c 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 @@ -190,6 +190,7 @@ export const formatTimelineResultToModel = ( export interface QueryTimelineById { apolloClient: ApolloClient | ApolloClient<{}> | undefined; duplicate?: boolean; + graphEventId?: string; timelineId: string; onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean; @@ -206,6 +207,7 @@ export interface QueryTimelineById { export const queryTimelineById = ({ apolloClient, duplicate = false, + graphEventId = '', timelineId, onOpenTimeline, openTimeline = true, @@ -238,6 +240,7 @@ export const queryTimelineById = ({ notes, timeline: { ...timeline, + graphEventId, show: openTimeline, }, to: getOr(to, 'dateRange.end', timeline), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap index 4e6cce618880b1..92782252719303 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -1,882 +1,942 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Timeline rendering renders correctly against snapshot 1`] = ` - - - - - + + + + + + - - - - - - - + Object { + "aggregatable": true, + "category": "host", + "columnHeaderType": "not-filtered", + "description": "Name of the host. +It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "example": "", + "id": "host.name", + "type": "keyword", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "source", + "columnHeaderType": "not-filtered", + "description": "IP address of the source. +Can be one or multiple IPv4 or IPv6 addresses.", + "example": "", + "id": "source.ip", + "type": "ip", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "destination", + "columnHeaderType": "not-filtered", + "description": "IP address of the destination. +Can be one or multiple IPv4 or IPv6 addresses.", + "example": "", + "id": "destination.ip", + "type": "ip", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "destination", + "columnHeaderType": "not-filtered", + "description": "Bytes sent from the source to the destination", + "example": "123", + "format": "bytes", + "id": "destination.bytes", + "type": "number", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "user", + "columnHeaderType": "not-filtered", + "description": "Short name or login of the user.", + "example": "albert", + "id": "user.name", + "type": "keyword", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "base", + "columnHeaderType": "not-filtered", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "id": "_id", + "type": "keyword", + "width": 180, + }, + Object { + "aggregatable": false, + "category": "base", + "columnHeaderType": "not-filtered", + "description": "For log events the message field contains the log message. +In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "id": "message", + "type": "text", + "width": 180, + }, + ] + } + dataProviders={ + Array [ + Object { + "and": Array [ + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 2", + "kqlQuery": "", + "name": "Provider 2", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 2", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 3", + "kqlQuery": "", + "name": "Provider 3", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 3", + }, + }, + ], + "enabled": true, + "excluded": false, + "id": "id-Provider 1", + "kqlQuery": "", + "name": "Provider 1", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 1", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 2", + "kqlQuery": "", + "name": "Provider 2", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 2", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 3", + "kqlQuery": "", + "name": "Provider 3", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 3", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 4", + "kqlQuery": "", + "name": "Provider 4", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 4", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 5", + "kqlQuery": "", + "name": "Provider 5", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 5", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 6", + "kqlQuery": "", + "name": "Provider 6", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 6", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 7", + "kqlQuery": "", + "name": "Provider 7", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 7", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 8", + "kqlQuery": "", + "name": "Provider 8", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 8", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 9", + "kqlQuery": "", + "name": "Provider 9", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 9", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 10", + "kqlQuery": "", + "name": "Provider 10", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 10", + }, + }, + ] + } + end={1521862432253} + eventType="raw" + filters={Array []} + id="foo" + indexPattern={ + Object { + "fields": Array [ + Object { + "aggregatable": true, + "name": "@timestamp", + "searchable": true, + "type": "date", + }, + Object { + "aggregatable": true, + "name": "@version", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.ephemeral_id", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.hostname", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.id", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test1", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test2", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test3", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test4", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test5", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test6", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test7", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test8", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "host.name", + "searchable": true, + "type": "string", + }, + ], + "title": "filebeat-*,auditbeat-*,packetbeat-*", + } + } + indexToAdd={Array []} + isLive={false} + itemsPerPage={5} + itemsPerPageOptions={ + Array [ + 5, + 10, + 20, + ] + } + kqlMode="search" + kqlQueryExpression="" + loadingIndexName={false} + onChangeItemsPerPage={[MockFunction]} + onClose={[MockFunction]} + onDataProviderEdited={[MockFunction]} + onDataProviderRemoved={[MockFunction]} + onToggleDataProviderEnabled={[MockFunction]} + onToggleDataProviderExcluded={[MockFunction]} + show={true} + showCallOutUnauthorizedMsg={false} + sort={ + Object { + "columnId": "@timestamp", + "sortDirection": "desc", + } + } + start={1521830963132} + toggleColumn={[MockFunction]} + usersViewing={ + Array [ + "elastic", + ] + } + /> + + + + + + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index ef744ab562e712..b478070b315783 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -15,6 +15,7 @@ import { eventHasNotes, getPinTooltip } from '../helpers'; import * as i18n from '../translations'; import { OnRowSelected } from '../../events'; import { Ecs } from '../../../../../graphql/types'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; export interface TimelineRowActionOnClick { eventId: string; @@ -27,7 +28,7 @@ export interface TimelineRowAction { displayType: 'icon' | 'contextMenu'; iconType?: string; id: string; - isActionDisabled?: boolean; + isActionDisabled?: (ecsData?: Ecs) => boolean; onClick: ({ eventId, ecsData }: TimelineRowActionOnClick) => void; content: string | JSX.Element; width?: number; @@ -83,24 +84,9 @@ export const Actions = React.memo( actionsColumnWidth={actionsColumnWidth} data-test-subj="event-actions-container" > - - - {loading && } - - {!loading && ( - - )} - - {showCheckboxes && ( - + {loadingEventIds.includes(eventId) ? ( ) : ( @@ -120,12 +106,28 @@ export const Actions = React.memo( )} + + + {loading && } + + {!loading && ( + + )} + + + <>{additionalActions} {!isEventViewer && ( <> - + ( - + + {showSelectAllCheckbox && ( + + + + + + )} + - + + {showEventsSelect && ( - + )} - {showSelectAllCheckbox && ( - - - - - - )} ( ...acc, icon: [ ...acc.icon, - + ( aria-label={action.ariaLabel} data-test-subj={`${action.dataTestSubj}-button`} iconType={action.iconType} - isDisabled={action.isActionDisabled ?? false} + isDisabled={ + action.isActionDisabled != null ? action.isActionDisabled(ecsData) : false + } onClick={() => action.onClick({ eventId: id, ecsData })} /> @@ -155,7 +158,9 @@ export const EventColumnView = React.memo( onClickCb(() => action.onClick({ eventId: id, ecsData }))} @@ -170,7 +175,11 @@ export const EventColumnView = React.memo( return grouped.contextMenu.length > 0 ? [ ...grouped.icon, - + => { } return 'raw'; }; + +export const showGraphView = (graphEventId?: string) => + graphEventId != null && graphEventId.length > 0; + +export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => { + return ( + get(['agent', 'type', 0], ecsData) === 'endpoint' && + get(['process', 'entity_id'], ecsData)?.length > 0 + ); +}; + +export const getInvestigateInResolverAction = ({ + dispatch, + timelineId, +}: { + dispatch: Dispatch; + timelineId: string; +}): TimelineRowAction => ({ + ariaLabel: i18n.ACTION_INVESTIGATE_IN_RESOLVER, + content: i18n.ACTION_INVESTIGATE_IN_RESOLVER, + dataTestSubj: 'investigate-in-resolver', + displayType: 'icon', + iconType: 'node', + id: 'investigateInResolver', + isActionDisabled: (ecsData?: Ecs) => !isInvestigateInResolverActionEnabled(ecsData), + onClick: ({ eventId }: TimelineRowActionOnClick) => + dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: eventId })), + width: DEFAULT_ICON_BUTTON_WIDTH, +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 775c26e82d27bc..9b96e0c49c73d7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -70,6 +70,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -108,6 +109,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -146,6 +148,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -186,6 +189,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -271,6 +275,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -316,6 +321,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index da8835d5903e19..46895c86de084a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -26,10 +26,13 @@ import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; import { ColumnHeaders } from './column_headers'; import { getActionsColumnWidth } from './column_headers/helpers'; import { Events } from './events'; +import { showGraphView } from './helpers'; import { ColumnRenderer } from './renderers/column_renderer'; import { RowRenderer } from './renderers/row_renderer'; import { Sort } from './sort'; import { useManageTimeline } from '../../manage_timeline'; +import { GraphOverlay } from '../../graph_overlay'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -38,6 +41,7 @@ export interface BodyProps { columnRenderers: ColumnRenderer[]; data: TimelineItem[]; getNotesByIds: (noteIds: string[]) => Note[]; + graphEventId?: string; height?: number; id: string; isEventViewer?: boolean; @@ -56,6 +60,7 @@ export interface BodyProps { pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; selectedEventIds: Readonly>; + show: boolean; showCheckboxes: boolean; sort: Sort; toggleColumn: (column: ColumnHeaderOptions) => void; @@ -72,6 +77,7 @@ export const Body = React.memo( data, eventIdToNoteIds, getNotesByIds, + graphEventId, height, id, isEventViewer = false, @@ -89,6 +95,7 @@ export const Body = React.memo( pinnedEventIds, rowRenderers, selectedEventIds, + show, showCheckboxes, sort, toggleColumn, @@ -108,7 +115,7 @@ export const Body = React.memo( if (v.displayType === 'icon') { return acc + (v.width ?? 0); } - const addWidth = hasContextMenu ? 0 : 26; + const addWidth = hasContextMenu ? 0 : DEFAULT_ICON_BUTTON_WIDTH; hasContextMenu = true; return acc + addWidth; }, 0) ?? 0 @@ -127,7 +134,15 @@ export const Body = React.memo( return ( <> - + {showGraphView(graphEventId) && ( + + )} + ( selectedEventIds, setSelected, clearSelected, + show, showCheckboxes, showRowRenderers, + graphEventId, sort, toggleColumn, unPinEvent, @@ -180,6 +183,7 @@ const StatefulBodyComponent = React.memo( data={data} eventIdToNoteIds={eventIdToNoteIds} getNotesByIds={getNotesByIds} + graphEventId={graphEventId} height={height} id={id} isEventViewer={isEventViewer} @@ -197,6 +201,7 @@ const StatefulBodyComponent = React.memo( pinnedEventIds={pinnedEventIds} rowRenderers={showRowRenderers ? rowRenderers : [plainRowRenderer]} selectedEventIds={selectedEventIds} + show={id === ACTIVE_TIMELINE_REDUX_ID ? show : true} showCheckboxes={showCheckboxes} sort={sort} toggleColumn={toggleColumn} @@ -209,6 +214,7 @@ const StatefulBodyComponent = React.memo( deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && deepEqual(prevProps.data, nextProps.data) && prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && + prevProps.graphEventId === nextProps.graphEventId && deepEqual(prevProps.notesById, nextProps.notesById) && prevProps.height === nextProps.height && prevProps.id === nextProps.id && @@ -216,6 +222,7 @@ const StatefulBodyComponent = React.memo( prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && prevProps.loadingEventIds === nextProps.loadingEventIds && prevProps.pinnedEventIds === nextProps.pinnedEventIds && + prevProps.show === nextProps.show && prevProps.selectedEventIds === nextProps.selectedEventIds && prevProps.showCheckboxes === nextProps.showCheckboxes && prevProps.showRowRenderers === nextProps.showRowRenderers && @@ -238,10 +245,12 @@ const makeMapStateToProps = () => { columns, eventIdToNoteIds, eventType, + graphEventId, isSelectAllChecked, loadingEventIds, pinnedEventIds, selectedEventIds, + show, showCheckboxes, showRowRenderers, } = timeline; @@ -250,12 +259,14 @@ const makeMapStateToProps = () => { columnHeaders: memoizedColumnHeaders(columns, browserFields), eventIdToNoteIds, eventType, + graphEventId, isSelectAllChecked, loadingEventIds, notesById: getNotesByIds(state), id, pinnedEventIds, selectedEventIds, + show, showCheckboxes, showRowRenderers, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index 98f544f30ae8b5..63b92d6b316cc8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -51,3 +51,10 @@ export const COLLAPSE = i18n.translate( defaultMessage: 'Collapse', } ); + +export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip', + { + defaultMessage: 'Investigate in Resolver', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index fb47eb331fdbbf..e8f1e73719234d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { FilterManager, IIndexPattern } from 'src/plugins/data/public'; import deepEqual from 'fast-deep-equal'; +import { showGraphView } from '../body/helpers'; import { DataProviders } from '../data_providers'; import { DataProvider } from '../data_providers/data_provider'; import { @@ -26,6 +27,7 @@ interface Props { browserFields: BrowserFields; dataProviders: DataProvider[]; filterManager: FilterManager; + graphEventId?: string; id: string; indexPattern: IIndexPattern; onDataProviderEdited: OnDataProviderEdited; @@ -42,6 +44,7 @@ const TimelineHeaderComponent: React.FC = ({ indexPattern, dataProviders, filterManager, + graphEventId, onDataProviderEdited, onDataProviderRemoved, onToggleDataProviderEnabled, @@ -59,24 +62,27 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - {show && ( - - )} - + {show && !showGraphView(graphEventId) && ( + <> + + + + + )} ); @@ -88,6 +94,7 @@ export const TimelineHeader = React.memo( deepEqual(prevProps.indexPattern, nextProps.indexPattern) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && prevProps.filterManager === nextProps.filterManager && + prevProps.graphEventId === nextProps.graphEventId && prevProps.onDataProviderEdited === nextProps.onDataProviderEdited && prevProps.onDataProviderRemoved === nextProps.onDataProviderRemoved && prevProps.onToggleDataProviderEnabled === nextProps.onToggleDataProviderEnabled && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index b5481e9d4eee24..a3fc692c3a8a85 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -153,3 +153,5 @@ export const combineQueries = ({ * the `Timeline` and the `Events Viewer` widget */ export const STATEFUL_EVENT_CSS_CLASS_NAME = 'event-column-view'; + +export const DEFAULT_ICON_BUTTON_WIDTH = 24; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 5ccc8911d1974b..83ac1a421958be 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -72,6 +72,7 @@ describe('StatefulTimeline', () => { eventType: 'raw', end: endDate, filters: [], + graphEventId: undefined, id: 'foo', isLive: false, isTimelineExists: false, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index df76eb350ace7f..a66c01d0b5d0b9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -41,6 +41,7 @@ const StatefulTimelineComponent = React.memo( eventType, end, filters, + graphEventId, id, isLive, isTimelineExists, @@ -168,6 +169,7 @@ const StatefulTimelineComponent = React.memo( end={end} eventType={eventType} filters={filters} + graphEventId={graphEventId} id={id} indexPattern={indexPattern} indexToAdd={indexToAdd} @@ -196,6 +198,7 @@ const StatefulTimelineComponent = React.memo( return ( prevProps.eventType === nextProps.eventType && prevProps.end === nextProps.end && + prevProps.graphEventId === nextProps.graphEventId && prevProps.id === nextProps.id && prevProps.isLive === nextProps.isLive && prevProps.itemsPerPage === nextProps.itemsPerPage && @@ -229,6 +232,7 @@ const makeMapStateToProps = () => { dataProviders, eventType, filters, + graphEventId, itemsPerPage, itemsPerPageOptions, kqlMode, @@ -245,6 +249,7 @@ const makeMapStateToProps = () => { eventType, end: input.timerange.to, filters: timelineFilter, + graphEventId, id, isLive: input.policy.kind === 'interval', isTimelineExists: getTimeline(state, id) != null, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx index 2ffbae1f7eb5c0..5e6f35e8397e48 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx @@ -50,7 +50,11 @@ describe('Insert timeline popover ', () => { payload: { id: 'timeline-id', show: false }, type: 'x-pack/security_solution/local/timeline/SHOW_TIMELINE', }); - expect(onTimelineChange).toBeCalledWith('Timeline title', '34578-3497-5893-47589-34759'); + expect(onTimelineChange).toBeCalledWith( + 'Timeline title', + '34578-3497-5893-47589-34759', + undefined + ); expect(mockDispatch.mock.calls[1][0]).toEqual({ payload: null, type: 'x-pack/security_solution/local/timeline/SET_INSERT_TIMELINE', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx index de199d9a1cc2eb..83417cdb51b699 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx @@ -19,7 +19,11 @@ import { setInsertTimeline } from '../../../store/timeline/actions'; interface InsertTimelinePopoverProps { isDisabled: boolean; hideUntitled?: boolean; - onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; + onTimelineChange: ( + timelineTitle: string, + timelineId: string | null, + graphEventId?: string + ) => void; } type Props = InsertTimelinePopoverProps; @@ -38,7 +42,11 @@ export const InsertTimelinePopoverComponent: React.FC = ({ useEffect(() => { if (insertTimeline != null) { dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false })); - onTimelineChange(insertTimeline.timelineTitle, insertTimeline.timelineSavedObjectId); + onTimelineChange( + insertTimeline.timelineTitle, + insertTimeline.timelineSavedObjectId, + insertTimeline.graphEventId + ); dispatch(setInsertTimeline(null)); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx index c3def9c4cbb292..c3bcd1c0ebe516 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { useCallback, useState } from 'react'; import { useBasePath } from '../../../../common/lib/kibana'; import { CursorPosition } from '../../../../common/components/markdown_editor'; @@ -16,8 +17,10 @@ export const useInsertTimeline = (form: FormHook, fieldNa end: 0, }); const handleOnTimelineChange = useCallback( - (title: string, id: string | null) => { - const builtLink = `${basePath}/app/security/timelines?timeline=(id:'${id}',isOpen:!t)`; + (title: string, id: string | null, graphEventId?: string) => { + const builtLink = `${basePath}/app/security/timelines?timeline=(id:'${id}'${ + !isEmpty(graphEventId) ? `,graphEventId:'${graphEventId}'` : '' + },isOpen:!t)`; const currentValue = form.getFormData()[fieldName]; const newValue: string = [ currentValue.slice(0, cursorPosition.start), @@ -28,16 +31,12 @@ export const useInsertTimeline = (form: FormHook, fieldNa ].join(''); form.setFieldValue(fieldName, newValue); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [form] - ); - const handleCursorChange = useCallback( - (cp: CursorPosition) => { - setCursorPosition(cp); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [cursorPosition] + [basePath, cursorPosition, fieldName, form] ); + const handleCursorChange = useCallback((cp: CursorPosition) => { + setCursorPosition(cp); + }, []); + return { cursorPosition, handleCursorChange, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx index d8c9d2ed02cc6e..aec09a95b4b195 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -17,6 +17,7 @@ jest.mock('../../../../common/lib/kibana', () => { useKibana: jest.fn().mockReturnValue({ services: { application: { + navigateToApp: jest.fn(), capabilities: { siem: { crud: true, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index f2e7d26c9e8516..528af23191ee9b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -20,7 +20,6 @@ import { import React, { useCallback } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; -import { useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { APP_ID } from '../../../../../common/constants'; @@ -28,11 +27,10 @@ import { TimelineTypeLiteral, TimelineStatus, TimelineType, + TimelineId, } from '../../../../../common/types/timeline'; -import { navTabs } from '../../../../app/home/home_navigations'; import { SecurityPageName } from '../../../../app/types'; import { timelineSelectors } from '../../../../timelines/store/timeline'; -import { useGetUrlSearch } from '../../../../common/components/navigation/use_get_url_search'; import { getCreateCaseUrl } from '../../../../common/components/link_to'; import { State } from '../../../../common/store'; import { useKibana } from '../../../../common/lib/kibana'; @@ -44,7 +42,7 @@ import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { NOTES_PANEL_WIDTH } from './notes_size'; import { ButtonContainer, DescriptionContainer, LabelText, NameField, StyledStar } from './styles'; import * as i18n from './translations'; -import { setInsertTimeline } from '../../../store/timeline/actions'; +import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; export const historyToolTip = 'The chronological history of actions related to this timeline'; @@ -139,6 +137,8 @@ export const Name = React.memo(({ timelineId, title, updateTitle }) = Name.displayName = 'Name'; interface NewCaseProps { + compact?: boolean; + graphEventId?: string; onClosePopover: () => void; timelineId: string; timelineStatus: TimelineStatus; @@ -146,44 +146,50 @@ interface NewCaseProps { } export const NewCase = React.memo( - ({ onClosePopover, timelineId, timelineStatus, timelineTitle }) => { - const history = useHistory(); - const urlSearch = useGetUrlSearch(navTabs.case); + ({ compact, graphEventId, onClosePopover, timelineId, timelineStatus, timelineTitle }) => { const dispatch = useDispatch(); const { savedObjectId } = useSelector((state: State) => timelineSelectors.selectTimeline(state, timelineId) ); const { navigateToApp } = useKibana().services.application; + const buttonText = compact ? i18n.ATTACH_TO_NEW_CASE : i18n.ATTACH_TIMELINE_TO_NEW_CASE; const handleClick = useCallback(() => { onClosePopover(); dispatch( setInsertTimeline({ + graphEventId, timelineId, timelineSavedObjectId: savedObjectId, timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, }) ); + dispatch(showTimeline({ id: TimelineId.active, show: false })); + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCreateCaseUrl(urlSearch), - }); - history.push({ - pathname: `/${SecurityPageName.case}/create`, + path: getCreateCaseUrl(), }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, navigateToApp, onClosePopover, history, timelineId, timelineTitle, urlSearch]); + }, [ + dispatch, + graphEventId, + navigateToApp, + onClosePopover, + savedObjectId, + timelineId, + timelineTitle, + ]); return ( - {i18n.ATTACH_TIMELINE_TO_NEW_CASE} + {buttonText} ); } @@ -191,28 +197,33 @@ export const NewCase = React.memo( NewCase.displayName = 'NewCase'; interface ExistingCaseProps { + compact?: boolean; onClosePopover: () => void; onOpenCaseModal: () => void; timelineStatus: TimelineStatus; } export const ExistingCase = React.memo( - ({ onClosePopover, onOpenCaseModal, timelineStatus }) => { + ({ compact, onClosePopover, onOpenCaseModal, timelineStatus }) => { const handleClick = useCallback(() => { onClosePopover(); onOpenCaseModal(); }, [onOpenCaseModal, onClosePopover]); + const buttonText = compact + ? i18n.ATTACH_TO_EXISTING_CASE + : i18n.ATTACH_TIMELINE_TO_EXISTING_CASE; return ( <> - {i18n.ATTACH_TIMELINE_TO_EXISTING_CASE} + {buttonText} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index 3078700a29d76b..1b76db409484f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -17,7 +17,6 @@ import { import { createStore, State } from '../../../../common/store'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Properties, showDescriptionThreshold, showNotesThreshold } from '.'; -import { SecurityPageName } from '../../../../app/types'; import { setInsertTimeline } from '../../../store/timeline/actions'; export { nextTick } from '../../../../../../../test_utils'; @@ -25,12 +24,13 @@ import { act } from 'react-dom/test-utils'; jest.mock('../../../../common/components/link_to'); +const mockNavigateToApp = jest.fn(); jest.mock('../../../../common/lib/kibana', () => { const original = jest.requireActual('../../../../common/lib/kibana'); return { ...original, - useKibana: jest.fn().mockReturnValue({ + useKibana: () => ({ services: { application: { capabilities: { @@ -38,7 +38,7 @@ jest.mock('../../../../common/lib/kibana', () => { crud: true, }, }, - navigateToApp: jest.fn(), + navigateToApp: mockNavigateToApp, }, }, }), @@ -63,7 +63,6 @@ jest.mock('react-redux', () => { useSelector: jest.fn().mockReturnValue({ savedObjectId: '1', urlState: {} }), }; }); -const mockHistoryPush = jest.fn(); jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -71,7 +70,7 @@ jest.mock('react-router-dom', () => { return { ...original, useHistory: () => ({ - push: mockHistoryPush, + push: jest.fn(), }), }; }); @@ -342,8 +341,7 @@ describe('Properties', () => { ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); wrapper.find('[data-test-subj="attach-timeline-case"]').first().simulate('click'); - - expect(mockHistoryPush).toBeCalledWith({ pathname: `/${SecurityPageName.case}/create` }); + expect(mockNavigateToApp).toBeCalledWith('securitySolution:case', { path: '/create' }); expect(mockDispatch).toBeCalledWith( setInsertTimeline({ timelineId: defaultProps.timelineId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 602a7c8191c7a2..8029d166a688a5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -46,6 +46,7 @@ interface Props { createTimeline: CreateTimeline; description: string; getNotesByIds: (noteIds: string[]) => Note[]; + graphEventId?: string; isDataInTimeline: boolean; isDatepickerLocked: boolean; isFavorite: boolean; @@ -79,6 +80,7 @@ export const Properties = React.memo( createTimeline, description, getNotesByIds, + graphEventId, isDataInTimeline, isDatepickerLocked, isFavorite, @@ -120,18 +122,21 @@ export const Properties = React.memo( const onRowClick = useCallback( (id: string) => { onCloseCaseModal(); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCaseDetailsUrl({ id }), - }); + dispatch( setInsertTimeline({ + graphEventId, timelineId, timelineSavedObjectId: currentTimeline.savedObjectId, timelineTitle: title.length > 0 ? title : i18n.UNTITLED_TIMELINE, }) ); + + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id }), + }); }, - [navigateToApp, onCloseCaseModal, currentTimeline, dispatch, timelineId, title] + [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] ); const datePickerWidth = useMemo( @@ -174,6 +179,7 @@ export const Properties = React.memo( associateNote={associateNote} description={description} getNotesByIds={getNotesByIds} + graphEventId={graphEventId} isDataInTimeline={isDataInTimeline} noteIds={noteIds} onButtonClick={onButtonClick} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx index 7d176d57b5d818..e20a3db80d8812 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx @@ -68,6 +68,7 @@ interface PropertiesRightComponentProps { associateNote: AssociateNote; description: string; getNotesByIds: (noteIds: string[]) => Note[]; + graphEventId?: string; isDataInTimeline: boolean; noteIds: string[]; onButtonClick: () => void; @@ -94,6 +95,7 @@ const PropertiesRightComponent: React.FC = ({ associateNote, description, getNotesByIds, + graphEventId, isDataInTimeline, noteIds, onButtonClick, @@ -166,6 +168,7 @@ const PropertiesRightComponent: React.FC = ({ EuiSelectableOption[]; onClosePopover: () => void; - onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; + onTimelineChange: ( + timelineTitle: string, + timelineId: string | null, + graphEventId?: string + ) => void; timelineType: TimelineTypeLiteral; } @@ -202,7 +206,8 @@ const SelectableTimelineComponent: React.FC = ({ isEmpty(selectedTimeline[0].title) ? i18nTimeline.UNTITLED_TIMELINE : selectedTimeline[0].title, - selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id + selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id, + selectedTimeline[0].graphEventId ?? '' ); } onClosePopover(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index aad80cbdfe3372..55bcbbecda2690 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -24,11 +24,12 @@ export const TimelineBodyGlobalStyle = createGlobalStyle` export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ className: `siemTimeline__body ${className}`, -}))<{ bodyHeight?: number }>` +}))<{ bodyHeight?: number; visible: boolean }>` height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; overflow: auto; scrollbar-width: thin; flex: 1; + visibility: ${({ visible }) => (visible ? 'visible' : 'hidden')}; &::-webkit-scrollbar { height: ${({ theme }) => theme.eui.euiScrollBar}; @@ -89,10 +90,9 @@ export const EventsTrHeader = styled.div.attrs(({ className }) => ({ export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thGroupActions ${className}`, -}))<{ actionsColumnWidth: number; justifyContent: string }>` +}))<{ actionsColumnWidth: number }>` display: flex; flex: 0 0 ${({ actionsColumnWidth }) => `${actionsColumnWidth}px`}; - justify-content: ${({ justifyContent }) => justifyContent}; min-width: 0; `; @@ -139,14 +139,17 @@ export const EventsTh = styled.div.attrs(({ className = '' }) => ({ export const EventsThContent = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thContent ${className}`, -}))<{ textAlign?: string }>` +}))<{ textAlign?: string; width?: number }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; min-width: 0; padding: ${({ theme }) => theme.eui.paddingSizes.xs}; text-align: ${({ textAlign }) => textAlign}; - width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ + width: ${({ width }) => + width != null + ? `${width}px` + : '100%'}; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; /* EVENTS BODY */ @@ -202,7 +205,6 @@ export const EventsTdGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tdGroupActions ${className}`, }))<{ actionsColumnWidth: number }>` display: flex; - justify-content: space-between; flex: 0 0 ${({ actionsColumnWidth }) => `${actionsColumnWidth}px`}; min-width: 0; `; @@ -234,14 +236,17 @@ export const EventsTd = styled.div.attrs(({ className = '', width }) `; export const EventsTdContent = styled.div.attrs(({ className }) => ({ - className: `siemEventsTable__tdContent ${className}`, -}))<{ textAlign?: string }>` + className: `siemEventsTable__tdContent ${className != null ? className : ''}`, +}))<{ textAlign?: string; width?: number }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; min-width: 0; padding: ${({ theme }) => theme.eui.paddingSizes.xs}; text-align: ${({ textAlign }) => textAlign}; - width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ + width: ${({ width }) => + width != null + ? `${width}px` + : '100%'}; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 96703941f616e3..79ec58711e06c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -103,7 +103,11 @@ describe('Timeline', () => { describe('rendering', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow( + + + + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 884d693ca6ade0..85e3d5d9478b63 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -7,6 +7,7 @@ import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React, { useState, useMemo, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { FlyoutHeaderWithCloseButton } from '../flyout/header_with_close_button'; @@ -16,6 +17,7 @@ import { Direction } from '../../../graphql/types'; import { useKibana } from '../../../common/lib/kibana'; import { ColumnHeaderOptions, KqlMode, EventType } from '../../../timelines/store/timeline/model'; import { defaultHeaders } from './body/column_headers/default_headers'; +import { getInvestigateInResolverAction } from './body/helpers'; import { Sort } from './body/sort'; import { StatefulBody } from './body/stateful_body'; import { DataProvider } from './data_providers/data_provider'; @@ -88,6 +90,7 @@ export interface Props { end: number; eventType?: EventType; filters: Filter[]; + graphEventId?: string; id: string; indexPattern: IIndexPattern; indexToAdd: string[]; @@ -119,6 +122,7 @@ export const TimelineComponent: React.FC = ({ end, eventType, filters, + graphEventId, id, indexPattern, indexToAdd, @@ -141,6 +145,7 @@ export const TimelineComponent: React.FC = ({ toggleColumn, usersViewing, }) => { + const dispatch = useDispatch(); const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); const combinedQueries = combineQueries({ @@ -168,9 +173,14 @@ export const TimelineComponent: React.FC = ({ initializeTimeline, setIsTimelineLoading, setTimelineFilterManager, + setTimelineRowActions, } = useManageTimeline(); useEffect(() => { initializeTimeline({ id, indexToAdd }); + setTimelineRowActions({ + id, + timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId: id })], + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -197,6 +207,7 @@ export const TimelineComponent: React.FC = ({ indexPattern={indexPattern} dataProviders={dataProviders} filterManager={filterManager} + graphEventId={graphEventId} onDataProviderEdited={onDataProviderEdited} onDataProviderRemoved={onDataProviderRemoved} onToggleDataProviderEnabled={onToggleDataProviderEnabled} diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts index 53d0b98570bcbf..e2a268e750b4a5 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts @@ -89,6 +89,9 @@ export const timelineQuery = gql` timezone type } + agent { + type + } auditd { result session @@ -285,6 +288,7 @@ export const timelineQuery = gql` name ppid args + entity_id executable title working_directory diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index c5df017604b0c6..55e6849fdb6c4a 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -87,6 +87,10 @@ export const removeProvider = actionCreator<{ export const showTimeline = actionCreator<{ id: string; show: boolean }>('SHOW_TIMELINE'); +export const updateTimelineGraphEventId = actionCreator<{ id: string; graphEventId: string }>( + 'UPDATE_TIMELINE_GRAPH_EVENT_ID' +); + export const unPinEvent = actionCreator<{ id: string; eventId: string }>('UN_PIN_EVENT'); export const updateTimeline = actionCreator<{ 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 15f956fa79d3cc..c0615d36f7a2e0 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 @@ -228,6 +228,26 @@ export const updateTimelineShowTimeline = ({ }; }; +export const updateGraphEventId = ({ + id, + graphEventId, + timelineById, +}: { + id: string; + graphEventId: string; + timelineById: TimelineById; +}): TimelineById => { + const timeline = timelineById[id]; + + return { + ...timelineById, + [id]: { + ...timeline, + graphEventId, + }, + }; +}; + interface ApplyDeltaToCurrentWidthParams { id: string; delta: number; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index caad70226365af..e8ea3c8d16e3a8 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -55,6 +55,8 @@ export interface TimelineModel { /** A map of events in this timeline to the chronologically ordered notes (in this timeline) associated with the event */ eventIdToNoteIds: Record; filters?: Filter[]; + /** When non-empty, display a graph view for this event */ + graphEventId?: string; /** The chronological history of actions related to this timeline */ historyIds: string[]; /** The chronological history of actions related to this timeline */ @@ -129,6 +131,7 @@ export type SubsetTimelineModel = Readonly< | 'description' | 'eventType' | 'eventIdToNoteIds' + | 'graphEventId' | 'highlightedDropAndProviderId' | 'historyIds' | 'isFavorite' @@ -165,4 +168,5 @@ export type SubsetTimelineModel = Readonly< export interface TimelineUrl { id: string; isOpen: boolean; + graphEventId?: string; } 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 3bdb16be79939a..6e7a36079a0c34 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 @@ -1788,6 +1788,7 @@ describe('Timeline', () => { isLoading: false, id: 'foo', savedObjectId: null, + showRowRenderers: true, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], @@ -1802,7 +1803,6 @@ describe('Timeline', () => { }, selectedEventIds: {}, show: true, - showRowRenderers: true, showCheckboxes: false, sort: { columnId: '@timestamp', diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 5e314f15974513..30b7f73c839d19 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -53,6 +53,7 @@ import { updateRange, updateSort, updateTimeline, + updateTimelineGraphEventId, updateTitle, upsertColumn, } from './actions'; @@ -94,6 +95,7 @@ import { updateTimelineTitle, upsertTimelineColumn, updateSavedQuery, + updateGraphEventId, updateFilters, updateTimelineEventType, } from './helpers'; @@ -194,6 +196,10 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: updateTimelineShowTimeline({ id, show, timelineById: state.timelineById }), })) + .case(updateTimelineGraphEventId, (state, { id, graphEventId }) => ({ + ...state, + timelineById: updateGraphEventId({ id, graphEventId, timelineById: state.timelineById }), + })) .case(applyDeltaToColumnWidth, (state, { id, columnId, delta }) => ({ ...state, timelineById: applyDeltaToTimelineColumnWidth({ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index 5262c72a6140c9..65798648f92c63 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -23,6 +23,7 @@ export interface TimelineById { } export interface InsertTimeline { + graphEventId?: string; timelineId: string; timelineSavedObjectId: string | null; timelineTitle: string; diff --git a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts index 9bf55cfe1ed2a0..52011e14167173 100644 --- a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts @@ -60,6 +60,10 @@ export const ecsSchema = gql` sequence: ToStringArray } + type AgentEcsField { + type: ToStringArray + } + type AuditdData { acct: ToStringArray terminal: ToStringArray @@ -110,6 +114,7 @@ export const ecsSchema = gql` name: ToStringArray ppid: ToNumberArray args: ToStringArray + entity_id: ToStringArray executable: ToStringArray title: ToStringArray thread: Thread @@ -425,6 +430,7 @@ export const ecsSchema = gql` type ECS { _id: String! _index: String + agent: AgentEcsField auditd: AuditdEcsFields destination: DestinationEcsFields dns: DnsEcsFields diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index 4a063647a183d9..40666b61939280 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -765,6 +765,8 @@ export interface Ecs { _index?: Maybe; + agent?: Maybe; + auditd?: Maybe; destination?: Maybe; @@ -812,6 +814,10 @@ export interface Ecs { system?: Maybe; } +export interface AgentEcsField { + type?: Maybe; +} + export interface AuditdEcsFields { result?: Maybe; @@ -1267,6 +1273,8 @@ export interface ProcessEcsFields { args?: Maybe; + entity_id?: Maybe; + executable?: Maybe; title?: Maybe; @@ -4083,6 +4091,8 @@ export namespace EcsResolvers { _index?: _IndexResolver, TypeParent, TContext>; + agent?: AgentResolver, TypeParent, TContext>; + auditd?: AuditdResolver, TypeParent, TContext>; destination?: DestinationResolver, TypeParent, TContext>; @@ -4140,6 +4150,11 @@ export namespace EcsResolvers { Parent, TContext >; + export type AgentResolver< + R = Maybe, + Parent = Ecs, + TContext = SiemContext + > = Resolver; export type AuditdResolver< R = Maybe, Parent = Ecs, @@ -4257,6 +4272,18 @@ export namespace EcsResolvers { > = Resolver; } +export namespace AgentEcsFieldResolvers { + export interface Resolvers { + type?: TypeResolver, TypeParent, TContext>; + } + + export type TypeResolver< + R = Maybe, + Parent = AgentEcsField, + TContext = SiemContext + > = Resolver; +} + export namespace AuditdEcsFieldsResolvers { export interface Resolvers { result?: ResultResolver, TypeParent, TContext>; @@ -5761,6 +5788,8 @@ export namespace ProcessEcsFieldsResolvers { args?: ArgsResolver, TypeParent, TContext>; + entity_id?: EntityIdResolver, TypeParent, TContext>; + executable?: ExecutableResolver, TypeParent, TContext>; title?: TitleResolver, TypeParent, TContext>; @@ -5795,6 +5824,11 @@ export namespace ProcessEcsFieldsResolvers { Parent = ProcessEcsFields, TContext = SiemContext > = Resolver; + export type EntityIdResolver< + R = Maybe, + Parent = ProcessEcsFields, + TContext = SiemContext + > = Resolver; export type ExecutableResolver< R = Maybe, Parent = ProcessEcsFields, @@ -9110,6 +9144,7 @@ export type IResolvers = { TimelineItem?: TimelineItemResolvers.Resolvers; TimelineNonEcsData?: TimelineNonEcsDataResolvers.Resolvers; Ecs?: EcsResolvers.Resolvers; + AgentEcsField?: AgentEcsFieldResolvers.Resolvers; AuditdEcsFields?: AuditdEcsFieldsResolvers.Resolvers; AuditdData?: AuditdDataResolvers.Resolvers; Summary?: SummaryResolvers.Resolvers; diff --git a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts index f2662c79d33937..ff474c4a841f62 100644 --- a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts +++ b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts @@ -76,12 +76,17 @@ export const processFieldsMap: Readonly> = { 'process.name': 'process.name', 'process.ppid': 'process.ppid', 'process.args': 'process.args', + 'process.entity_id': 'process.entity_id', 'process.executable': 'process.executable', 'process.title': 'process.title', 'process.thread': 'process.thread', 'process.working_directory': 'process.working_directory', }; +export const agentFieldsMap: Readonly> = { + 'agent.type': 'agent.type', +}; + export const userFieldsMap: Readonly> = { 'user.domain': 'user.domain', 'user.id': 'user.id', @@ -327,6 +332,7 @@ export const eventFieldsMap: Readonly> = { timestamp: '@timestamp', '@timestamp': '@timestamp', message: 'message', + ...{ ...agentFieldsMap }, ...{ ...auditdMap }, ...{ ...destinationFieldsMap }, ...{ ...dnsFieldsMap },