From 30d9772c1b1b2eeee9886db3ccb1e2ad1d3ab158 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 26 Jun 2020 19:18:25 -0600 Subject: [PATCH] [Security] `Investigate in Resolver` Timeline Integration (#70111) (#70136) ## [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 2029c5169c2cd..6d82897aaf010 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 f843bf6881846..9ff368aff2bf6 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 ba6102312fef6..ec088c111e3bb 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 251e0278b11ba..6d5471404ab4d 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 6b4baac0ff26c..9e38b14c4334a 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 977c7808b6c86..f345346d620cb 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 c270a99d3c51e..7f4267bc5e2b3 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 efd6221bbfbd0..ab03e2199474c 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 3c8c7c21d72a0..48547212bb6c0 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 dc4a8ae78bf46..b5088fe51b446 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 480070fda9594..7addfaaf7c5fc 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 a3e93ff3c90eb..a3937107936b6 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 8ad32d6e2cad0..9fe48cd2f0190 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 0000000000000..fe38dd79176a5 --- /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 0000000000000..c7cd9253de038 --- /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 c8a47798f169c..520215cde4862 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 4e6cce618880b..9278225271930 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 ef744ab562e71..b478070b31578 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 775c26e82d27b..9b96e0c49c73d 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 da8835d5903e1..46895c86de084 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 98f544f30ae8b..63b92d6b316cc 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 fb47eb331fdbb..e8f1e73719234 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 b5481e9d4eee2..a3fc692c3a8a8 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 5ccc8911d1974..83ac1a421958b 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 df76eb350ace7..a66c01d0b5d0b 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 2ffbae1f7eb5c..5e6f35e8397e4 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 de199d9a1cc2e..83417cdb51b69 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 c3def9c4cbb29..c3bcd1c0ebe51 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 d8c9d2ed02cc6..aec09a95b4b19 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 f2e7d26c9e851..528af23191ee9 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 3078700a29d76..1b76db409484f 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 602a7c8191c7a..8029d166a688a 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 7d176d57b5d81..e20a3db80d881 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 aad80cbdfe337..55bcbbecda269 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 96703941f616e..79ec58711e06c 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 884d693ca6ade..85e3d5d9478b6 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 53d0b98570bcb..e2a268e750b4a 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 c5df017604b0c..55e6849fdb6c4 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 15f956fa79d3c..c0615d36f7a2e 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 caad70226365a..e8ea3c8d16e3a 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 3bdb16be79939..6e7a36079a0c3 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 5e314f1597451..30b7f73c839d1 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 5262c72a6140c..65798648f92c6 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 9bf55cfe1ed2a..52011e1416717 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 4a063647a183d..40666b6193928 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 f2662c79d3393..ff474c4a841f6 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 },