diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 4ca130c6af7b4..42e1d33ab6dba 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -6,11 +6,18 @@ */ export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const ALERT_STATUS_ROUTE = '/internal/session_view/alert_status_route'; export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; export const ALERTS_INDEX = '.siem-signals-default'; export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; +export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid'; export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ hh:mm:ss.SSS'; +export const ALERT_STATUS = { + OPEN: 'open', + ACKNOWLEDGED: 'acknowledged', + CLOSED: 'closed', +}; // We fetch a large number of events per page to mitigate a few design caveats in session viewer // 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there @@ -26,6 +33,23 @@ export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ hh:mm:ss.SSS'; // search functionality will instead use a separate ES backend search to avoid this. // 3. Fewer round trips to the backend! export const PROCESS_EVENTS_PER_PAGE = 1000; + +// As an initial approach, we won't be implementing pagination for alerts. +// Instead we will load this fixed amount of alerts as a maximum for a session. +// This could cause an edge case, where a noisy rule that alerts on every process event +// causes a session to only list and highlight up to 1000 alerts, even though there could +// be far greater than this amount. UX should be added to let the end user know this is +// happening and to revise their rule to be more specific. +export const ALERTS_PER_PAGE = 1000; + +// when showing the count of alerts in details panel tab, if the number +// exceeds ALERT_COUNT_THRESHOLD we put a + next to it, e.g 999+ +export const ALERT_COUNT_THRESHOLD = 999; + +// react-query caching keys +export const QUERY_KEY_PROCESS_EVENTS = 'sessionViewProcessEvents'; +export const QUERY_KEY_ALERTS = 'sessionViewAlerts'; + export const MOUSE_EVENT_PLACEHOLDER = { stopPropagation: () => undefined } as React.MouseEvent; export const DEBOUNCE_TIMEOUT = 500; diff --git a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts index 83cd250d45691..f9ace9fee7a75 100644 --- a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts +++ b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts @@ -920,6 +920,7 @@ export const childProcessMock: Process = { hasOutput: () => false, hasAlerts: () => false, getAlerts: () => [], + updateAlertsStatus: (_) => undefined, hasExec: () => false, getOutput: () => '', getDetails: () => @@ -998,6 +999,7 @@ export const processMock: Process = { hasOutput: () => false, hasAlerts: () => false, getAlerts: () => [], + updateAlertsStatus: (_) => undefined, hasExec: () => false, getOutput: () => '', getDetails: () => @@ -1173,6 +1175,7 @@ export const mockProcessMap = mockEvents.reduce( hasOutput: () => false, hasAlerts: () => false, getAlerts: () => [], + updateAlertsStatus: (_) => undefined, hasExec: () => false, getOutput: () => '', getDetails: () => event, diff --git a/x-pack/plugins/session_view/common/types/process_tree/index.ts b/x-pack/plugins/session_view/common/types/process_tree/index.ts index 746c1b2093661..3475e8d425908 100644 --- a/x-pack/plugins/session_view/common/types/process_tree/index.ts +++ b/x-pack/plugins/session_view/common/types/process_tree/index.ts @@ -5,6 +5,13 @@ * 2.0. */ +export interface AlertStatusEventEntityIdMap { + [alertUuid: string]: { + status: string; + processEntityId: string; + }; +} + export const enum EventKind { event = 'event', signal = 'signal', @@ -150,6 +157,7 @@ export interface Process { hasOutput(): boolean; hasAlerts(): boolean; getAlerts(): ProcessEvent[]; + updateAlertsStatus(updatedAlertsStatus: AlertStatusEventEntityIdMap): void; hasExec(): boolean; getOutput(): string; getDetails(): ProcessEvent; diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts index 9092009a7d291..39947da471499 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts @@ -4,12 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { cloneDeep } from 'lodash'; import { - mockData, + mockEvents, + mockAlerts, mockProcessMap, } from '../../../common/mocks/constants/session_view_process.mock'; -import { Process, ProcessMap } from '../../../common/types/process_tree'; import { + AlertStatusEventEntityIdMap, + Process, + ProcessMap, + ProcessEvent, +} from '../../../common/types/process_tree'; +import { ALERT_STATUS } from '../../../common/constants'; +import { + updateAlertEventStatus, updateProcessMap, buildProcessTree, searchProcessTree, @@ -20,8 +29,6 @@ const SESSION_ENTITY_ID = '3d0192c6-7c54-5ee6-a110-3539a7cf42bc'; const SEARCH_QUERY = 'vi'; const SEARCH_RESULT_PROCESS_ID = '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727'; -const mockEvents = mockData[0].events; - describe('process tree hook helpers tests', () => { let processMap: ProcessMap; @@ -73,4 +80,42 @@ describe('process tree hook helpers tests', () => { // session leader should have autoExpand to be true expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeTruthy(); }); + + it('updateAlertEventStatus works', () => { + let events: ProcessEvent[] = cloneDeep([...mockEvents, ...mockAlerts]); + const updatedAlertsStatus: AlertStatusEventEntityIdMap = { + [mockAlerts[0].kibana?.alert.uuid!]: { + status: ALERT_STATUS.CLOSED, + processEntityId: mockAlerts[0].process.entity_id, + }, + }; + + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[0].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.OPEN); + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[1].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.OPEN); + + events = updateAlertEventStatus(events, updatedAlertsStatus); + + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[0].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.CLOSED); + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[1].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.OPEN); + }); }); 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 d3d7af1c62eda..df4a6cf70abec 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 @@ -4,9 +4,40 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { Process, ProcessEvent, ProcessMap } from '../../../common/types/process_tree'; +import { + AlertStatusEventEntityIdMap, + Process, + ProcessEvent, + ProcessMap, +} from '../../../common/types/process_tree'; import { ProcessImpl } from './hooks'; +// if given event is an alert, and it exist in updatedAlertsStatus, update the alert's status +// with the updated status value in updatedAlertsStatus Map +export const updateAlertEventStatus = ( + events: ProcessEvent[], + updatedAlertsStatus: AlertStatusEventEntityIdMap +) => + events.map((event) => { + // do nothing if event is not an alert + if (!event.kibana) { + return event; + } + + return { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana.alert, + workflow_status: + updatedAlertsStatus[event.kibana.alert?.uuid]?.status ?? + event.kibana.alert?.workflow_status, + }, + }, + }; + }); + // given a page of new events, add these events to the appropriate process class model // create a new process if none are created and return the mutated processMap export const updateProcessMap = (processMap: ProcessMap, events: ProcessEvent[]) => { 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 dfd34a5d10094..fb00344d5e280 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 @@ -8,6 +8,7 @@ import _ from 'lodash'; import memoizeOne from 'memoize-one'; import { useState, useEffect } from 'react'; import { + AlertStatusEventEntityIdMap, EventAction, EventKind, Process, @@ -15,13 +16,19 @@ import { ProcessMap, ProcessEventsPage, } from '../../../common/types/process_tree'; -import { processNewEvents, searchProcessTree, autoExpandProcessTree } from './helpers'; +import { + updateAlertEventStatus, + processNewEvents, + searchProcessTree, + autoExpandProcessTree, +} from './helpers'; import { sortProcesses } from '../../../common/utils/sort_processes'; interface UseProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; searchQuery?: string; + updatedAlertsStatus: AlertStatusEventEntityIdMap; } export class ProcessImpl implements Process { @@ -103,6 +110,10 @@ export class ProcessImpl implements Process { return this.filterEventsByKind(this.events, EventKind.signal); } + updateAlertsStatus(updatedAlertsStatus: AlertStatusEventEntityIdMap) { + this.events = updateAlertEventStatus(this.events, updatedAlertsStatus); + } + hasExec() { return !!this.findEventByAction(this.events, EventAction.exec); } @@ -129,6 +140,7 @@ export class ProcessImpl implements Process { // only used to auto expand parts of the tree that could be of interest. isUserEntered() { const event = this.getDetails(); + const { pid, tty, @@ -181,7 +193,12 @@ export class ProcessImpl implements Process { }); } -export const useProcessTree = ({ sessionEntityId, data, searchQuery }: UseProcessTreeDeps) => { +export const useProcessTree = ({ + sessionEntityId, + data, + searchQuery, + updatedAlertsStatus, +}: UseProcessTreeDeps) => { // initialize map, as well as a placeholder for session leader process // we add a fake session leader event, sourced from wide event data. // this is because we might not always have a session leader event @@ -250,5 +267,13 @@ export const useProcessTree = ({ sessionEntityId, data, searchQuery }: UseProces sessionLeader.orphans = orphans; + // update alert status in processMap for alerts in updatedAlertsStatus + Object.keys(updatedAlertsStatus).forEach((alertUuid) => { + const process = processMap[updatedAlertsStatus[alertUuid].processEntityId]; + if (process) { + process.updateAlertsStatus(updatedAlertsStatus); + } + }); + return { sessionLeader: processMap[sessionEntityId], processMap, searchResults }; }; diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx index bdaeb0cdce2b4..9fa7900d04b0d 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -10,7 +10,7 @@ import { mockData } from '../../../common/mocks/constants/session_view_process.m import { Process } from '../../../common/types/process_tree'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { ProcessImpl } from './hooks'; -import { ProcessTree } from './index'; +import { ProcessTreeDeps, ProcessTree } from './index'; describe('ProcessTree component', () => { let render: () => ReturnType; @@ -18,6 +18,18 @@ describe('ProcessTree component', () => { let mockedContext: AppContextTestRender; const sessionLeader = mockData[0].events[0]; const sessionLeaderVerboseTest = mockData[0].events[3]; + const props: ProcessTreeDeps = { + sessionEntityId: sessionLeader.process.entity_id, + data: mockData, + isFetching: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + fetchPreviousPage: jest.fn(), + hasPreviousPage: false, + onProcessSelected: jest.fn(), + updatedAlertsStatus: {}, + handleOnAlertDetailsClosed: jest.fn(), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); @@ -25,18 +37,7 @@ describe('ProcessTree component', () => { describe('When ProcessTree is mounted', () => { it('should render given a valid sessionEntityId and data', () => { - renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={jest.fn()} - /> - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); }); @@ -47,17 +48,7 @@ describe('ProcessTree component', () => { expect(process?.id).toBe(jumpToEvent.process.entity_id); }); renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - jumpToEvent={jumpToEvent} - onProcessSelected={onProcessSelected} - /> + ); expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); @@ -70,16 +61,7 @@ describe('ProcessTree component', () => { expect(process?.id).toBe(sessionLeader.process.entity_id); }); renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={onProcessSelected} - /> + ); expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); @@ -88,20 +70,7 @@ describe('ProcessTree component', () => { }); it('When Verbose mode is OFF, it should not show all childrens', () => { - renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={jest.fn()} - timeStampOn={true} - verboseModeOn={false} - /> - ); + renderResult = mockedContext.render(); expect(renderResult.queryByText('cat')).toBeFalsy(); const selectionArea = renderResult.queryAllByTestId('sessionView:processTreeNode'); @@ -112,20 +81,7 @@ describe('ProcessTree component', () => { }); it('When Verbose mode is ON, it should show all childrens', () => { - renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={jest.fn()} - timeStampOn={true} - verboseModeOn={true} - /> - ); + renderResult = mockedContext.render(); expect(renderResult.queryByText('cat')).toBeTruthy(); const selectionArea = renderResult.queryAllByTestId('sessionView:processTreeNode'); @@ -139,18 +95,7 @@ describe('ProcessTree component', () => { const mockSelectedProcess = new ProcessImpl(mockData[0].events[0].process.entity_id); renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - selectedProcess={mockSelectedProcess} - onProcessSelected={jest.fn()} - verboseModeOn={true} - /> + ); expect( @@ -162,19 +107,7 @@ describe('ProcessTree component', () => { // change the selected process const mockSelectedProcess2 = new ProcessImpl(mockData[0].events[1].process.entity_id); - renderResult.rerender( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - selectedProcess={mockSelectedProcess2} - onProcessSelected={jest.fn()} - /> - ); + renderResult.rerender(); expect( renderResult diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx index 06942498aa967..4b489797c7e26 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -10,13 +10,18 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { ProcessTreeNode } from '../process_tree_node'; import { BackToInvestigatedAlert } from '../back_to_investigated_alert'; import { useProcessTree } from './hooks'; -import { Process, ProcessEventsPage, ProcessEvent } from '../../../common/types/process_tree'; +import { + AlertStatusEventEntityIdMap, + Process, + ProcessEventsPage, + ProcessEvent, +} from '../../../common/types/process_tree'; import { useScroll } from '../../hooks/use_scroll'; import { useStyles } from './styles'; type FetchFunction = () => void; -interface ProcessTreeDeps { +export interface ProcessTreeDeps { // process.entity_id to act as root node (typically a session (or entry session) leader). sessionEntityId: string; @@ -36,6 +41,11 @@ interface ProcessTreeDeps { selectedProcess?: Process | null; onProcessSelected: (process: Process | null) => void; setSearchResults?: (results: Process[]) => void; + + // a map for alerts with updated status and process.entity_id + updatedAlertsStatus: AlertStatusEventEntityIdMap; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string) => void; timeStampOn?: boolean; verboseModeOn?: boolean; } @@ -53,6 +63,9 @@ export const ProcessTree = ({ selectedProcess, onProcessSelected, setSearchResults, + updatedAlertsStatus, + loadAlertDetails, + handleOnAlertDetailsClosed, timeStampOn, verboseModeOn, }: ProcessTreeDeps) => { @@ -64,6 +77,7 @@ export const ProcessTree = ({ sessionEntityId, data, searchQuery, + updatedAlertsStatus, }); const scrollerRef = useRef(null); @@ -189,6 +203,8 @@ export const ProcessTree = ({ selectedProcessId={selectedProcess?.id} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} timeStampOn={timeStampOn} verboseModeOn={verboseModeOn} /> diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx index 635ac09682eae..2a56a0ae2be67 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx @@ -8,17 +8,26 @@ import React from 'react'; import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; -import { ProcessTreeAlert } from './index'; +import { ProcessTreeAlertDeps, ProcessTreeAlert } from './index'; const mockAlert = mockAlerts[0]; const TEST_ID = `sessionView:sessionViewAlertDetail-${mockAlert.kibana?.alert.uuid}`; const ALERT_RULE_NAME = mockAlert.kibana?.alert.rule.name; const ALERT_STATUS = mockAlert.kibana?.alert.workflow_status; +const EXPAND_BUTTON_TEST_ID = `sessionView:sessionViewAlertDetailExpand-${mockAlert.kibana?.alert.uuid}`; describe('ProcessTreeAlerts component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + const props: ProcessTreeAlertDeps = { + alert: mockAlert, + isInvestigated: false, + isSelected: false, + onClick: jest.fn(), + selectAlert: jest.fn(), + handleOnAlertDetailsClosed: jest.fn(), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); @@ -26,15 +35,7 @@ describe('ProcessTreeAlerts component', () => { describe('When ProcessTreeAlert is mounted', () => { it('should render alert row correctly', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId(TEST_ID)).toBeTruthy(); expect(renderResult.queryByText(ALERT_RULE_NAME!)).toBeTruthy(); @@ -42,21 +43,34 @@ describe('ProcessTreeAlerts component', () => { }); it('should execute onClick callback', async () => { - const mockFn = jest.fn(); - renderResult = mockedContext.render( - - ); + const onClick = jest.fn(); + renderResult = mockedContext.render(); const alertRow = renderResult.queryByTestId(TEST_ID); expect(alertRow).toBeTruthy(); alertRow?.click(); - expect(mockFn).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should automatically call selectAlert when isInvestigated is true', async () => { + const selectAlert = jest.fn(); + renderResult = mockedContext.render( + + ); + + expect(selectAlert).toHaveBeenCalledTimes(1); + }); + + it('should execute loadAlertDetails callback when clicking on expand button', async () => { + const loadAlertDetails = jest.fn(); + renderResult = mockedContext.render( + + ); + + const expandButton = renderResult.queryByTestId(EXPAND_BUTTON_TEST_ID); + expect(expandButton).toBeTruthy(); + expandButton?.click(); + expect(loadAlertDetails).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx index d0d4c84252513..5ec1c4a7693c3 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx @@ -5,18 +5,20 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui'; import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree'; import { getBadgeColorFromAlertStatus } from './helpers'; import { useStyles } from './styles'; -interface ProcessTreeAlertDeps { +export interface ProcessTreeAlertDeps { alert: ProcessEvent; isInvestigated: boolean; isSelected: boolean; onClick: (alert: ProcessEventAlert | null) => void; selectAlert: (alertUuid: string) => void; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string, status?: string) => void; } export const ProcessTreeAlert = ({ @@ -25,16 +27,30 @@ export const ProcessTreeAlert = ({ isSelected, onClick, selectAlert, + loadAlertDetails, + handleOnAlertDetailsClosed, }: ProcessTreeAlertDeps) => { const styles = useStyles({ isInvestigated, isSelected }); const { uuid, rule, workflow_status: status } = alert.kibana?.alert || {}; useEffect(() => { - if (isInvestigated && isSelected && uuid) { + if (isInvestigated && uuid) { selectAlert(uuid); } - }, [isInvestigated, isSelected, uuid, selectAlert]); + }, [isInvestigated, uuid, selectAlert]); + + const handleExpandClick = useCallback(() => { + if (loadAlertDetails && uuid) { + loadAlertDetails(uuid, () => handleOnAlertDetailsClosed(uuid)); + } + }, [handleOnAlertDetailsClosed, loadAlertDetails, uuid]); + + const handleClick = useCallback(() => { + if (alert.kibana?.alert) { + onClick(alert.kibana.alert); + } + }, [alert.kibana?.alert, onClick]); if (!(alert.kibana && rule)) { return null; @@ -42,10 +58,6 @@ export const ProcessTreeAlert = ({ const { name } = rule; - const handleClick = () => { - onClick(alert.kibana?.alert ?? null); - }; - return ( - + {name} diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx index c4dbaf817cff2..2333c71d36a51 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -8,12 +8,17 @@ import React from 'react'; import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; -import { ProcessTreeAlerts } from './index'; +import { ProcessTreeAlertsDeps, ProcessTreeAlerts } from './index'; describe('ProcessTreeAlerts component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + const props: ProcessTreeAlertsDeps = { + alerts: mockAlerts, + onAlertSelected: jest.fn(), + handleOnAlertDetailsClosed: jest.fn(), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); @@ -21,17 +26,13 @@ describe('ProcessTreeAlerts component', () => { describe('When ProcessTreeAlerts is mounted', () => { it('should return null if no alerts', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeNull(); }); it('should return an array of alert details', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); mockAlerts.forEach((alert) => { @@ -49,7 +50,7 @@ describe('ProcessTreeAlerts component', () => { it('should execute onAlertSelected when clicking on an alert', async () => { const mockFn = jest.fn(); renderResult = mockedContext.render( - + ); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx index dcca29dcf4f84..c97ccfe253605 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -11,11 +11,13 @@ import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_t import { ProcessTreeAlert } from '../process_tree_alert'; import { MOUSE_EVENT_PLACEHOLDER } from '../../../common/constants'; -interface ProcessTreeAlertsDeps { +export interface ProcessTreeAlertsDeps { alerts: ProcessEvent[]; jumpToAlertID?: string; isProcessSelected?: boolean; onAlertSelected: (e: MouseEvent) => void; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string) => void; } export function ProcessTreeAlerts({ @@ -23,6 +25,8 @@ export function ProcessTreeAlerts({ jumpToAlertID, isProcessSelected = false, onAlertSelected, + loadAlertDetails, + handleOnAlertDetailsClosed, }: ProcessTreeAlertsDeps) { const [selectedAlert, setSelectedAlert] = useState(null); const styles = useStyles(); @@ -57,15 +61,18 @@ export function ProcessTreeAlerts({ } }, []); + const handleAlertClick = useCallback( + (alert: ProcessEventAlert | null) => { + onAlertSelected(MOUSE_EVENT_PLACEHOLDER); + setSelectedAlert(alert); + }, + [onAlertSelected] + ); + if (alerts.length === 0) { return null; } - const handleAlertClick = (alert: ProcessEventAlert | null) => { - onAlertSelected(MOUSE_EVENT_PLACEHOLDER); - setSelectedAlert(alert); - }; - return (
); })} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 0791f21e81846..2e82e822f0c82 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -36,6 +36,7 @@ describe('ProcessTreeNode component', () => { }, } as unknown as RefObject, onChangeJumpToEventVisibility: jest.fn(), + handleOnAlertDetailsClosed: (_alertUuid: string) => {}, }; beforeEach(() => { 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 bc2eb4706c73d..b1c42dd95efb9 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 @@ -43,6 +43,8 @@ export interface ProcessDeps { verboseModeOn?: boolean; scrollerRef: RefObject; onChangeJumpToEventVisibility: (isVisible: boolean, isAbove: boolean) => void; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string) => void; } /** @@ -60,6 +62,8 @@ export function ProcessTreeNode({ verboseModeOn = true, scrollerRef, onChangeJumpToEventVisibility, + loadAlertDetails, + handleOnAlertDetailsClosed, }: ProcessDeps) { const textRef = useRef(null); @@ -123,18 +127,21 @@ export function ProcessTreeNode({ setAlertsExpanded(!alertsExpanded); }, [alertsExpanded]); - const onProcessClicked = (e: MouseEvent) => { - e.stopPropagation(); + const onProcessClicked = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); - const selection = window.getSelection(); + const selection = window.getSelection(); - // do not select the command if the user was just selecting text for copy. - if (selection && selection.type === 'Range') { - return; - } + // do not select the command if the user was just selecting text for copy. + if (selection && selection.type === 'Range') { + return; + } - onProcessSelected?.(process); - }; + onProcessSelected?.(process); + }, + [onProcessSelected, process] + ); const processDetails = process.getDetails(); @@ -248,6 +255,8 @@ export function ProcessTreeNode({ jumpToAlertID={jumpToAlertID} isProcessSelected={selectedProcessId === process.id} onAlertSelected={onProcessClicked} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} /> )} @@ -267,6 +276,8 @@ export function ProcessTreeNode({ verboseModeOn={verboseModeOn} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index 17574cfd28074..a134a366c4168 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -5,12 +5,21 @@ * 2.0. */ import { useEffect, useState } from 'react'; -import { useInfiniteQuery } from 'react-query'; +import { useQuery, useInfiniteQuery } from 'react-query'; import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; import { CoreStart } from 'kibana/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { ProcessEvent, ProcessEventResults } from '../../../common/types/process_tree'; -import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE } from '../../../common/constants'; +import { + AlertStatusEventEntityIdMap, + ProcessEvent, + ProcessEventResults, +} from '../../../common/types/process_tree'; +import { + PROCESS_EVENTS_ROUTE, + PROCESS_EVENTS_PER_PAGE, + ALERT_STATUS_ROUTE, + QUERY_KEY_ALERTS, +} from '../../../common/constants'; export const useFetchSessionViewProcessEvents = ( sessionEntityId: string, @@ -75,6 +84,46 @@ export const useFetchSessionViewProcessEvents = ( return query; }; +export const useFetchAlertStatus = ( + updatedAlertsStatus: AlertStatusEventEntityIdMap, + alertUuid: string +) => { + const { http } = useKibana().services; + const cachingKeys = [QUERY_KEY_ALERTS, alertUuid]; + const query = useQuery( + cachingKeys, + async () => { + if (!alertUuid) { + return updatedAlertsStatus; + } + + const res = await http.get(ALERT_STATUS_ROUTE, { + query: { + alertUuid, + }, + }); + + // TODO: add error handling + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return { + ...updatedAlertsStatus, + [alertUuid]: { + status: events[0]?.kibana?.alert.workflow_status ?? '', + processEntityId: events[0]?.process?.entity_id ?? '', + }, + }; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + return query; +}; + export const useSearchQuery = () => { const [searchQuery, setSearchQuery] = useState(''); const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index 4b8881a88d7b0..af4eb6114a0a2 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { EuiEmptyPrompt, EuiButton, @@ -16,34 +16,40 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { SectionLoading } from '../../shared_imports'; import { ProcessTree } from '../process_tree'; -import { Process } from '../../../common/types/process_tree'; +import { AlertStatusEventEntityIdMap, Process } from '../../../common/types/process_tree'; import { DisplayOptionsState } from '../../../common/types/session_view'; import { SessionViewDeps } from '../../types'; import { SessionViewDetailPanel } from '../session_view_detail_panel'; import { SessionViewSearchBar } from '../session_view_search_bar'; import { SessionViewDisplayOptions } from '../session_view_display_options'; import { useStyles } from './styles'; -import { useFetchSessionViewProcessEvents } from './hooks'; +import { useFetchAlertStatus, useFetchSessionViewProcessEvents } from './hooks'; /** * The main wrapper component for the session view. */ -export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { +export const SessionView = ({ + sessionEntityId, + height, + jumpToEvent, + loadAlertDetails, +}: SessionViewDeps) => { const [isDetailOpen, setIsDetailOpen] = useState(false); const [selectedProcess, setSelectedProcess] = useState(null); - - const styles = useStyles({ height }); - - const onProcessSelected = useCallback((process: Process | null) => { - setSelectedProcess(process); - }, []); - const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [displayOptions, setDisplayOptions] = useState({ timestamp: true, verboseMode: true, }); + const [fetchAlertStatus, setFetchAlertStatus] = useState([]); + const [updatedAlertsStatus, setUpdatedAlertsStatus] = useState({}); + + const styles = useStyles({ height }); + + const onProcessSelected = useCallback((process: Process | null) => { + setSelectedProcess(process); + }, []); const { data, @@ -58,10 +64,25 @@ export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionVie const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; const renderIsLoading = isFetching && !data; const renderDetails = isDetailOpen && selectedProcess; + const { data: newUpdatedAlertsStatus } = useFetchAlertStatus( + updatedAlertsStatus, + fetchAlertStatus[0] ?? '' + ); + + useEffect(() => { + if (fetchAlertStatus) { + setUpdatedAlertsStatus({ ...newUpdatedAlertsStatus }); + } + }, [fetchAlertStatus, newUpdatedAlertsStatus]); + + const handleOnAlertDetailsClosed = useCallback((alertUuid: string) => { + setFetchAlertStatus([alertUuid]); + }, []); const toggleDetailPanel = useCallback(() => { setIsDetailOpen(!isDetailOpen); }, [isDetailOpen]); + const handleOptionChange = useCallback((checkedOptions: DisplayOptionsState) => { setDisplayOptions(checkedOptions); }, []); @@ -182,6 +203,9 @@ export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionVie fetchNextPage={fetchNextPage} fetchPreviousPage={fetchPreviousPage} setSearchResults={setSearchResults} + updatedAlertsStatus={updatedAlertsStatus} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} timeStampOn={displayOptions.timestamp} verboseModeOn={displayOptions.verboseMode} /> diff --git a/x-pack/plugins/session_view/public/methods/index.tsx b/x-pack/plugins/session_view/public/methods/index.tsx index 1eecdcbb3e50e..3654e296e7412 100644 --- a/x-pack/plugins/session_view/public/methods/index.tsx +++ b/x-pack/plugins/session_view/public/methods/index.tsx @@ -15,15 +15,11 @@ const queryClient = new QueryClient(); const SessionViewLazy = lazy(() => import('../components/session_view')); -export const getSessionViewLazy = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { +export const getSessionViewLazy = (props: SessionViewDeps) => { return ( }> - + ); diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts index d84623af7c0ed..3a7ef376bd426 100644 --- a/x-pack/plugins/session_view/public/types.ts +++ b/x-pack/plugins/session_view/public/types.ts @@ -20,6 +20,12 @@ export interface SessionViewDeps { // if provided, the session view will jump to and select the provided event if it belongs to the session leader // session view will fetch a page worth of events starting from jumpToEvent as well as a page backwards. jumpToEvent?: ProcessEvent; + // Callback to open the alerts flyout + loadAlertDetails?: ( + alertUuid: string, + // Callback used when alert flyout panel is closed + handleOnAlertDetailsClosed: () => void + ) => void; } export interface EuiTabProps { diff --git a/x-pack/plugins/session_view/server/routes/alert_status_route.ts b/x-pack/plugins/session_view/server/routes/alert_status_route.ts new file mode 100644 index 0000000000000..70ce32ee72020 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alert_status_route.ts @@ -0,0 +1,56 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { ElasticsearchClient } from 'kibana/server'; +import { IRouter } from '../../../../../src/core/server'; +import { ALERT_STATUS_ROUTE, ALERTS_INDEX, ALERT_UUID_PROPERTY } from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; + +export const registerAlertStatusRoute = (router: IRouter) => { + router.get( + { + path: ALERT_STATUS_ROUTE, + validate: { + query: schema.object({ + alertUuid: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { alertUuid } = request.query; + const body = await searchAlertByUuid(client, alertUuid); + + return response.ok({ body }); + } + ); +}; + +export const searchAlertByUuid = async (client: ElasticsearchClient, alertUuid: string) => { + const search = await client.search({ + index: [ALERTS_INDEX], + ignore_unavailable: true, // on a new installation the .siem-signals-default index might not be created yet. + body: { + query: { + match: { + [ALERT_UUID_PROPERTY]: alertUuid, + }, + }, + size: 1, + }, + }); + + const events = search.hits.hits.map((hit: any) => { + // TODO: re-eval if this is needed after updated ECS mappings are applied. + // the .siem-signals-default index flattens many properties. this util unflattens them. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + return { events }; +}; diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts index 7b9cfb45f580b..b8cb80dc1d1d4 100644 --- a/x-pack/plugins/session_view/server/routes/index.ts +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -6,9 +6,11 @@ */ import { IRouter } from '../../../../../src/core/server'; import { registerProcessEventsRoute } from './process_events_route'; +import { registerAlertStatusRoute } from './alert_status_route'; import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; export const registerRoutes = (router: IRouter) => { registerProcessEventsRoute(router); + registerAlertStatusRoute(router); sessionEntryLeadersRoute(router); };