From fa00f8b66146b6ba2147494f724554d4b2a89a8e Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Thu, 10 Mar 2022 17:06:04 -0500 Subject: [PATCH] Changes to make session_view work with generated data --- .../common/components/events_viewer/index.tsx | 33 ++-- .../components/graph_overlay/index.tsx | 14 +- .../hooks/use_detail_panel.test.tsx | 150 ++++++++++++++++++ .../side_panel/hooks/use_detail_panel.tsx | 139 ++++++++++++++++ .../timeline/session_tab_content/index.tsx | 37 ++++- .../public/components/process_tree/helpers.ts | 11 +- .../public/components/process_tree/hooks.ts | 11 +- .../components/process_tree_node/index.tsx | 2 +- x-pack/plugins/session_view/public/types.ts | 2 +- .../timelines/common/types/timeline/index.ts | 1 + 10 files changed, 367 insertions(+), 33 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 946e71eb4856b..3c25b5abfc3ec 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -11,7 +11,12 @@ import styled from 'styled-components'; import type { Filter } from '@kbn/es-query'; import { inputsModel, State } from '../../store'; import { inputsActions } from '../../store/actions'; -import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; +import { + ControlColumnProps, + RowRenderer, + TimelineId, + TimelineTabs, +} from '../../../../common/types/timeline'; import { APP_ID, APP_UI_ID } from '../../../../common/constants'; import { timelineActions } from '../../../timelines/store/timeline'; import type { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; @@ -24,7 +29,6 @@ import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererDataView } from '../../containers/sourcerer'; import type { EntityType } from '../../../../../timelines/common'; import { TGridCellAction } from '../../../../../timelines/common/types'; -import { DetailsPanel } from '../../../timelines/components/side_panel'; import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { FIELDS_WITHOUT_CELL_ACTIONS } from '../../lib/cell_actions/constants'; import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana'; @@ -33,6 +37,7 @@ import { useFieldBrowserOptions, FieldEditorActions, } from '../../../timelines/components/fields_browser'; +import { useDetailPanel } from '../../../timelines/components/side_panel/hooks/use_detail_panel'; const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; @@ -156,11 +161,22 @@ const StatefulEventsViewerComponent: React.FC = ({ const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]); const trailingControlColumns: ControlColumnProps[] = EMPTY_CONTROL_COLUMNS; + + const { openDetailsPanel, DetailsPanel } = useDetailPanel({ + isFlyoutView: true, + entityType, + sourcererScope: SourcererScopeName.timeline, + timelineId: id, + tabType: TimelineTabs.query, + }); + const graphOverlay = useMemo(() => { const shouldShowOverlay = (graphEventId != null && graphEventId.length > 0) || sessionViewId !== null; - return shouldShowOverlay ? : null; - }, [graphEventId, id, sessionViewId]); + return shouldShowOverlay ? ( + + ) : null; + }, [graphEventId, id, sessionViewId, openDetailsPanel]); const setQuery = useCallback( (inspect, loading, refetch) => { dispatch(inputsActions.setQuery({ id, inputId: 'global', inspect, loading, refetch })); @@ -240,14 +256,7 @@ const StatefulEventsViewerComponent: React.FC = ({ })} - + {DetailsPanel} ); 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 index af03bc7d0d7d2..245e86a96b41b 100644 --- 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 @@ -81,7 +81,8 @@ const ScrollableFlexItem = styled(EuiFlexItem)` width: 100%; `; -interface OwnProps { +interface GraphOverlayProps { + openDetailsPanel: (eventId?: string, onClose?: () => void) => void; timelineId: TimelineId; } @@ -133,7 +134,7 @@ NavigationComponent.displayName = 'NavigationComponent'; const Navigation = React.memo(NavigationComponent); -const GraphOverlayComponent: React.FC = ({ timelineId }) => { +const GraphOverlayComponent: React.FC = ({ timelineId, openDetailsPanel }) => { const dispatch = useDispatch(); const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); @@ -147,8 +148,13 @@ const GraphOverlayComponent: React.FC = ({ timelineId }) => { (state) => (getTimeline(state, timelineId) ?? timelineDefaults).sessionViewId ); const sessionViewMain = useMemo(() => { - return sessionViewId !== null ? sessionView.getSessionView(sessionViewId) : null; - }, [sessionView, sessionViewId]); + return sessionViewId !== null + ? sessionView.getSessionView({ + sessionEntityId: sessionViewId, + loadAlertDetails: openDetailsPanel, + }) + : null; + }, [sessionView, sessionViewId, openDetailsPanel]); const getStartSelector = useMemo(() => startSelector(), []); const getEndSelector = useMemo(() => endSelector(), []); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx new file mode 100644 index 0000000000000..4bc5ba44c1695 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useDetailPanel, UseDetailPanelConfig } from './use_detail_panel'; +import { timelineActions } from '../../../store/timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { TimelineId, TimelineTabs } from '../../../../../common/types'; + +const mockDispatch = jest.fn(); +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/hooks/use_selector'); +jest.mock('../../../store/timeline'); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); +jest.mock('../../../../common/containers/sourcerer', () => { + const mockSourcererReturn = { + browserFields: {}, + docValueFields: [], + loading: true, + indexPattern: {}, + selectedPatterns: [], + missingPatterns: [], + }; + return { + useSourcererDataView: jest.fn().mockReturnValue(mockSourcererReturn), + }; +}); + +describe('useDetailPanel', () => { + const defaultProps: UseDetailPanelConfig = { + sourcererScope: SourcererScopeName.detections, + timelineId: TimelineId.test, + }; + const mockGetExpandedDetail = jest.fn().mockImplementation(() => ({})); + beforeEach(() => { + (useDeepEqualSelector as jest.Mock).mockImplementation((cb) => { + return mockGetExpandedDetail(); + }); + }); + afterEach(() => { + (useDeepEqualSelector as jest.Mock).mockClear(); + }); + + test('should return openDetailsPanel fn, handleOnDetailsPanelClosed fn, shouldShowDetailsPanel, and the DetailsPanel component', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + expect(result.current.openDetailsPanel).toBeDefined(); + expect(result.current.handleOnDetailsPanelClosed).toBeDefined(); + expect(result.current.shouldShowDetailsPanel).toBe(false); + expect(result.current.DetailsPanel).toBeNull(); + }); + }); + + test('should fire redux action to open details panel', async () => { + const testEventId = '123'; + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + result.current?.openDetailsPanel(testEventId); + + expect(mockDispatch).toHaveBeenCalled(); + expect(timelineActions.toggleDetailPanel).toHaveBeenCalled(); + }); + }); + + test('should call provided onClose callback provided to openDetailsPanel fn', async () => { + const testEventId = '123'; + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + result.current?.openDetailsPanel(testEventId, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + test('should call the last onClose callback provided to openDetailsPanel fn', async () => { + // Test that the onClose ref is properly updated + const testEventId = '123'; + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + const secondMockOnClose = jest.fn(); + result.current?.openDetailsPanel(testEventId, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + + result.current?.openDetailsPanel(testEventId, secondMockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(secondMockOnClose).toHaveBeenCalled(); + }); + }); + + test('should show the details panel', async () => { + mockGetExpandedDetail.mockImplementation(() => ({ + [TimelineTabs.session]: { + panelView: 'somePanel', + }, + })); + const updatedProps = { + ...defaultProps, + tabType: TimelineTabs.session, + }; + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(updatedProps); + }); + await waitForNextUpdate(); + + expect(result.current.DetailsPanel).toMatchInlineSnapshot(` + + `); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx new file mode 100644 index 0000000000000..3b50f17e16c04 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import type { EntityType } from '../../../../../../timelines/common'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { activeTimeline } from '../../../containers/active_timeline_context'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { timelineDefaults } from '../../../store/timeline/defaults'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { DetailsPanel as DetailsPanelComponent } from '..'; + +export interface UseDetailPanelConfig { + entityType?: EntityType; + isFlyoutView?: boolean; + sourcererScope: SourcererScopeName; + timelineId: TimelineId; + tabType?: TimelineTabs; +} + +export interface UseDetailPanelReturn { + openDetailsPanel: (eventId?: string, onClose?: () => void) => void; + handleOnDetailsPanelClosed: () => void; + DetailsPanel: JSX.Element | null; + shouldShowDetailsPanel: boolean; +} + +export const useDetailPanel = ({ + entityType, + isFlyoutView, + sourcererScope, + timelineId, + tabType, +}: UseDetailPanelConfig): UseDetailPanelReturn => { + const { browserFields, docValueFields, selectedPatterns, runtimeMappings } = + useSourcererDataView(sourcererScope); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const dispatch = useDispatch(); + + const expandedDetail = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedDetail + ); + const onPanelClose = useRef(() => {}); + + const shouldShowDetailsPanel = useMemo(() => { + if ( + tabType && + expandedDetail && + expandedDetail[tabType] && + !!expandedDetail[tabType]?.panelView + ) { + return true; + } + return false; + }, [expandedDetail, tabType]); + + const loadDetailsPanel = useCallback( + (eventId?: string) => { + if (eventId) { + dispatch( + timelineActions.toggleDetailPanel({ + panelView: 'eventDetail', + tabType, + timelineId, + params: { + eventId, + indexName: selectedPatterns.join(','), + }, + }) + ); + } + }, + [dispatch, selectedPatterns, tabType, timelineId] + ); + + const openDetailsPanel = useCallback( + (eventId?: string, onClose?: () => void) => { + loadDetailsPanel(eventId); + onPanelClose.current = onClose ?? (() => {}); + }, + [loadDetailsPanel] + ); + + const handleOnDetailsPanelClosed = useCallback(() => { + console.log('ON PANEL CLOSE: ', onPanelClose.current); // eslint-disable-line + if (onPanelClose.current) onPanelClose.current(); + dispatch(timelineActions.toggleDetailPanel({ tabType, timelineId })); + + if ( + tabType && + expandedDetail[tabType]?.panelView && + timelineId === TimelineId.active && + shouldShowDetailsPanel + ) { + activeTimeline.toggleExpandedDetail({}); + } + }, [dispatch, timelineId, expandedDetail, tabType, shouldShowDetailsPanel]); + + const DetailsPanel = useMemo( + () => + shouldShowDetailsPanel ? ( + + ) : null, + [ + browserFields, + docValueFields, + entityType, + handleOnDetailsPanelClosed, + isFlyoutView, + runtimeMappings, + shouldShowDetailsPanel, + tabType, + timelineId, + ] + ); + + return { + openDetailsPanel, + handleOnDetailsPanelClosed, + shouldShowDetailsPanel, + DetailsPanel, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx index 700462f00aa79..dbf7731160115 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx @@ -10,9 +10,11 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { timelineSelectors } from '../../../store/timeline'; import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineId } from '../../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useDetailPanel } from '../../side_panel/hooks/use_detail_panel'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; const FullWidthFlexGroup = styled(EuiFlexGroup)` margin: 0; @@ -25,23 +27,48 @@ const ScrollableFlexItem = styled(EuiFlexItem)` overflow: hidden; `; +const VerticalRule = styled.div` + width: 2px; + height: 100%; + background: ${({ theme }) => theme.eui.euiColorLightShade}; +`; + interface Props { timelineId: TimelineId; } const SessionTabContent: React.FC = ({ timelineId }) => { const { sessionView } = useKibana().services; - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const sessionViewId = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).sessionViewId ); + const { openDetailsPanel, shouldShowDetailsPanel, DetailsPanel } = useDetailPanel({ + sourcererScope: SourcererScopeName.timeline, + timelineId, + tabType: TimelineTabs.session, + }); const sessionViewMain = useMemo(() => { - return sessionViewId !== null ? sessionView.getSessionView(sessionViewId) : null; - }, [sessionView, sessionViewId]); + return sessionViewId !== null + ? sessionView.getSessionView({ + sessionEntityId: sessionViewId, + loadAlertDetails: openDetailsPanel, + }) + : null; + }, [openDetailsPanel, sessionView, sessionViewId]); - return {sessionViewMain}; + return ( + + {sessionViewMain} + {shouldShowDetailsPanel && ( + <> + + {DetailsPanel} + + )} + + ); }; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts index df4a6cf70abec..4493db36fd95e 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts @@ -77,7 +77,6 @@ export const buildProcessTree = ( events.forEach((event) => { const process = processMap[event.process.entity_id]; const parentProcess = processMap[event.process.parent?.entity_id]; - // if session leader, or process already has a parent, return if (process.id === sessionEntityId || process.parent) { return; @@ -105,12 +104,14 @@ export const buildProcessTree = ( // with this new page of events processed, lets try re-parent any orphans orphans?.forEach((process) => { - const parentProcess = processMap[process.getDetails().process.parent.entity_id]; + const parentProcessId = process.getDetails().process.parent?.entity_id; - if (parentProcess) { + if (parentProcessId) { + const parentProcess = processMap[parentProcessId]; process.parent = parentProcess; // handy for recursive operations (like auto expand) - - parentProcess.children.push(process); + if (parentProcess !== undefined) { + parentProcess.children.push(process); + } } else { newOrphans.push(process); } diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index fb00344d5e280..73ac5ee682746 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -149,11 +149,11 @@ export class ProcessImpl implements Process { group_leader: groupLeader, } = event.process; - const parentIsASessionLeader = parent.pid === sessionLeader.pid; // possibly bash, zsh or some other shell - const processIsAGroupLeader = pid === groupLeader.pid; + const parentIsASessionLeader = parent && sessionLeader && parent.pid === sessionLeader.pid; + const processIsAGroupLeader = groupLeader && pid === groupLeader.pid; const sessionIsInteractive = !!tty; - return sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader; + return !!(sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader); } getMaxAlertLevel() { @@ -181,15 +181,16 @@ export class ProcessImpl implements Process { // to be used as a source for the most up to date details // on the processes lifecycle. getDetailsMemo = memoizeOne((events: ProcessEvent[]) => { + // TODO: add these to generator const actionsToFind = [EventAction.fork, EventAction.exec, EventAction.end]; const filtered = events.filter((processEvent) => { - return actionsToFind.includes(processEvent.event.action); + return true; }); // because events is already ordered by @timestamp we take the last event // which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event. // If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time) - return filtered[filtered.length - 1] || ({} as ProcessEvent); + return filtered[filtered.length - 1]; }); } diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index b1c42dd95efb9..e93a92d4eac6f 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -166,7 +166,7 @@ export function ProcessTreeNode({ const shouldRenderChildren = childrenExpanded && children && children.length > 0; const childrenTreeDepth = depth + 1; - const showUserEscalation = user.id !== parent.user.id; + const showUserEscalation = user?.id !== parent?.user?.id; const interactiveSession = !!tty; const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; const hasExec = process.hasExec(); diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts index 6cc051bee0795..a9778d454a226 100644 --- a/x-pack/plugins/session_view/public/types.ts +++ b/x-pack/plugins/session_view/public/types.ts @@ -70,5 +70,5 @@ export interface SessionViewStart { }: { onOpenSessionView: (eventId: string) => void; }) => JSX.Element; - getSessionView: (sessionEntityId: string) => JSX.Element; + getSessionView: (sessionDeps: SessionViewDeps) => JSX.Element; } diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index a6c8ed1b74bff..6572d6cb695fa 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -462,6 +462,7 @@ export enum TimelineTabs { graph = 'graph', notes = 'notes', pinned = 'pinned', + session = 'session', eql = 'eql', }