diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 42e1d33ab6dba..9e8e1ae0d5e04 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -6,10 +6,11 @@ */ export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const ALERTS_ROUTE = '/internal/session_view/alerts_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 ALERTS_INDEX = '.alerts-security.alerts-default'; // TODO: changes to remove this and use AlertsClient instead to get indices. 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'; diff --git a/x-pack/plugins/session_view/kibana.json b/x-pack/plugins/session_view/kibana.json index ff9d849016c55..4807315569d34 100644 --- a/x-pack/plugins/session_view/kibana.json +++ b/x-pack/plugins/session_view/kibana.json @@ -1,6 +1,6 @@ { "id": "sessionView", - "version": "8.0.0", + "version": "1.0.0", "kibanaVersion": "kibana", "owner": { "name": "Security Team", @@ -8,10 +8,11 @@ }, "requiredPlugins": [ "data", - "timelines" + "timelines", + "ruleRegistry" ], "requiredBundles": [ - "kibanaReact", + "kibanaReact", "esUiShared" ], "server": true, diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx new file mode 100644 index 0000000000000..1d0c9d0227699 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx @@ -0,0 +1,88 @@ +/* + * 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 from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { + DetailPanelAlertActions, + BUTTON_TEST_ID, + SHOW_DETAILS_TEST_ID, + JUMP_TO_PROCESS_TEST_ID, +} from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import userEvent from '@testing-library/user-event'; +import { ProcessImpl } from '../process_tree/hooks'; + +describe('DetailPanelAlertActions component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockShowAlertDetails = jest.fn((uuid) => uuid); + let mockOnProcessSelected = jest.fn((process) => process); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockShowAlertDetails = jest.fn((uuid) => uuid); + mockOnProcessSelected = jest.fn((process) => process); + }); + + describe('When DetailPanelAlertActions is mounted', () => { + it('renders a popover when button is clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + expect(renderResult.queryByTestId(SHOW_DETAILS_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(JUMP_TO_PROCESS_TEST_ID)).toBeTruthy(); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls alert flyout callback when View details clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(SHOW_DETAILS_TEST_ID)); + expect(mockShowAlertDetails.mock.calls.length).toBe(1); + expect(mockShowAlertDetails.mock.results[0].value).toBe(mockEvent.kibana?.alert.uuid); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls onProcessSelected when Jump to process clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(JUMP_TO_PROCESS_TEST_ID)); + expect(mockOnProcessSelected.mock.calls.length).toBe(1); + expect(mockOnProcessSelected.mock.results[0].value).toBeInstanceOf(ProcessImpl); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx new file mode 100644 index 0000000000000..4c7e3fdfaa961 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx @@ -0,0 +1,105 @@ +/* + * 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, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiPopover, EuiContextMenuPanel, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { ProcessImpl } from '../process_tree/hooks'; + +export const BUTTON_TEST_ID = 'sessionView:detailPanelAlertActionsBtn'; +export const SHOW_DETAILS_TEST_ID = 'sessionView:detailPanelAlertActionShowDetails'; +export const JUMP_TO_PROCESS_TEST_ID = 'sessionView:detailPanelAlertActionJumpToProcess'; + +interface DetailPanelAlertActionsDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; +} + +/** + * Detail panel alert context menu actions + */ +export const DetailPanelAlertActions = ({ + event, + onShowAlertDetails, + onProcessSelected, +}: DetailPanelAlertActionsDeps) => { + const [isPopoverOpen, setPopover] = useState(false); + + const onClosePopover = useCallback(() => { + setPopover(false); + }, []); + + const onToggleMenu = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const onJumpToAlert = useCallback(() => { + const process = new ProcessImpl(event.process.entity_id); + process.addEvent(event); + + onProcessSelected(process); + setPopover(false); + }, [event, onProcessSelected]); + + const onShowDetails = useCallback(() => { + if (event.kibana) { + onShowAlertDetails(event.kibana.alert.uuid); + setPopover(false); + } + }, [event, onShowAlertDetails]); + + if (!event.kibana) { + return null; + } + + const { uuid } = event.kibana.alert; + + const menuItems = [ + + + , + + + , + ]; + + return ( + + } + isOpen={isPopoverOpen} + closePopover={onClosePopover} + panelPaddingSize="none" + anchorPosition="leftCenter" + > + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts new file mode 100644 index 0000000000000..14d0be374b5d1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts @@ -0,0 +1,107 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +interface StylesDeps { + minimal?: boolean; + isInvestigated?: boolean; +} + +export const useStyles = ({ minimal = false, isInvestigated = false }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: mediumPadding, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx new file mode 100644 index 0000000000000..daa472cd6e5b4 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx @@ -0,0 +1,84 @@ +/* + * 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 } from 'react'; +import { EuiIcon, EuiText, EuiAccordion, EuiNotificationBadge } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from '../detail_panel_alert_list_item/styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; + +export const ALERT_GROUP_ITEM_TEST_ID = 'sessionView:detailPanelAlertGroupItem'; +export const ALERT_GROUP_ITEM_COUNT_TEST_ID = 'sessionView:detailPanelAlertGroupCount'; +export const ALERT_GROUP_ITEM_TITLE_TEST_ID = 'sessionView:detailPanelAlertGroupTitle'; + +interface DetailPanelAlertsGroupItemDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertGroupItem = ({ + alerts, + onProcessSelected, + onShowAlertDetails, +}: DetailPanelAlertsGroupItemDeps) => { + const styles = useStyles(); + + const alertsCount = useMemo(() => { + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + if (!alerts[0].kibana) { + return null; + } + + const { rule } = alerts[0].kibana.alert; + + return ( + +

+ + {rule.name} +

+ + } + css={styles.alertItem} + extraAction={ + + {alertsCount} + + } + > + {alerts.map((event) => { + const key = 'minimal_' + event.kibana?.alert.uuid; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx new file mode 100644 index 0000000000000..516d04539432e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx @@ -0,0 +1,137 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiIcon, + EuiText, + EuiAccordion, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertActions } from '../detail_panel_alert_actions'; + +export const ALERT_LIST_ITEM_TEST_ID = 'sessionView:detailPanelAlertListItem'; +export const ALERT_LIST_ITEM_ARGS_TEST_ID = 'sessionView:detailPanelAlertListItemArgs'; +export const ALERT_LIST_ITEM_TIMESTAMP_TEST_ID = 'sessionView:detailPanelAlertListItemTimestamp'; + +interface DetailPanelAlertsListItemDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; + isInvestigated?: boolean; + minimal?: boolean; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertListItem = ({ + event, + onProcessSelected, + onShowAlertDetails, + isInvestigated, + minimal, +}: DetailPanelAlertsListItemDeps) => { + const styles = useStyles(minimal, isInvestigated); + + if (!event.kibana) { + return null; + } + + const timestamp = event['@timestamp']; + const { uuid, name } = event.kibana.alert.rule; + const { args } = event.process; + + const forceState = !isInvestigated ? 'open' : undefined; + + return minimal ? ( +
+ + + + + {timestamp} + + + + + + + + {args.join(' ')} + + +
+ ) : ( + +

+ + {name} +

+ + } + initialIsOpen={true} + forceState={forceState} + css={styles.alertItem} + extraAction={ + + } + > + + + {timestamp} + + + + {args.join(' ')} + + + {isInvestigated && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts new file mode 100644 index 0000000000000..7672bb942ff32 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts @@ -0,0 +1,112 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +export const useStyles = (minimal = false, isInvestigated = false) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: minimal ? size.s : size.m, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + const minimalContextMenu: CSSObject = { + float: 'right', + }; + + const minimalHR: CSSObject = { + marginBottom: 0, + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + minimalContextMenu, + minimalHR, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx new file mode 100644 index 0000000000000..a915f8e285ad1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx @@ -0,0 +1,251 @@ +/* + * 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 from 'react'; + +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelAlertTab } from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { fireEvent } from '@testing-library/dom'; +import { + INVESTIGATED_ALERT_TEST_ID, + VIEW_MODE_TOGGLE, + ALERTS_TAB_EMPTY_STATE_TEST_ID, +} from './index'; +import { + ALERT_LIST_ITEM_TEST_ID, + ALERT_LIST_ITEM_ARGS_TEST_ID, + ALERT_LIST_ITEM_TIMESTAMP_TEST_ID, +} from '../detail_panel_alert_list_item/index'; +import { + ALERT_GROUP_ITEM_TEST_ID, + ALERT_GROUP_ITEM_COUNT_TEST_ID, + ALERT_GROUP_ITEM_TITLE_TEST_ID, +} from '../detail_panel_alert_group_item/index'; + +const ACCORDION_BUTTON_CLASS = '.euiAccordion__button'; +const VIEW_MODE_GROUP = 'groupView'; +const ARIA_EXPANDED_ATTR = 'aria-expanded'; + +describe('DetailPanelAlertTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); + }); + + describe('When DetailPanelAlertTab is mounted', () => { + it('renders a list of alerts for the session (defaulting to list view mode)', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeFalsy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('List view'); + }); + + it('renders a list of alerts grouped by rule when group-view clicked', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('Group view'); + }); + + it('renders a sticky investigated alert (outside of main list) if one is set', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + }); + + it('investigated alert should be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + }); + + it('non investigated alert should NOT be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('grouped alerts should be expandable/collapsible (default to collapsed)', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('each alert list item should show a timestamp and process arguments', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TIMESTAMP_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0]['@timestamp'] + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_ARGS_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0].process.args.join(' ') + ); + }); + + it('each alert group should show a rule title and alert count', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_COUNT_TEST_ID)).toHaveTextContent('2'); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TITLE_TEST_ID)).toHaveTextContent( + mockAlerts[0].kibana?.alert.rule.name || '' + ); + }); + + it('renders an empty state when there are no alerts', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(ALERTS_TAB_EMPTY_STATE_TEST_ID)).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx new file mode 100644 index 0000000000000..7fa47f4f5daf7 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx @@ -0,0 +1,146 @@ +/* + * 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, { useState, useMemo } from 'react'; +import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { groupBy } from 'lodash'; +import { ProcessEvent, Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { DetailPanelAlertGroupItem } from '../detail_panel_alert_group_item'; + +export const ALERTS_TAB_EMPTY_STATE_TEST_ID = 'sessionView:detailPanelAlertsEmptyState'; +export const INVESTIGATED_ALERT_TEST_ID = 'sessionView:detailPanelInvestigatedAlert'; +export const VIEW_MODE_TOGGLE = 'sessionView:detailPanelAlertsViewMode'; + +interface DetailPanelAlertTabDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; + investigatedAlert?: ProcessEvent; +} + +const VIEW_MODE_LIST = 'listView'; +const VIEW_MODE_GROUP = 'groupView'; + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelAlertTab = ({ + alerts, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, +}: DetailPanelAlertTabDeps) => { + const styles = useStyles(); + const [viewMode, setViewMode] = useState(VIEW_MODE_LIST); + const viewModes = [ + { + id: VIEW_MODE_LIST, + label: i18n.translate('xpack.sessionView.alertDetailsTab.listView', { + defaultMessage: 'List view', + }), + }, + { + id: VIEW_MODE_GROUP, + label: i18n.translate('xpack.sessionView.alertDetailsTab.groupView', { + defaultMessage: 'Group view', + }), + }, + ]; + + const filteredAlerts = useMemo(() => { + return alerts.filter((event) => { + const isInvestigatedAlert = + event.kibana?.alert.uuid === investigatedAlert?.kibana?.alert.uuid; + return !isInvestigatedAlert; + }); + }, [investigatedAlert, alerts]); + + const groupedAlerts = useMemo(() => { + return groupBy(filteredAlerts, (event) => event.kibana?.alert.rule.uuid); + }, [filteredAlerts]); + + if (alerts.length === 0) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + + return ( +
+ + {investigatedAlert && ( +
+ + +
+ )} + + {viewMode === VIEW_MODE_LIST + ? filteredAlerts.map((event) => { + const key = event.kibana?.alert.uuid; + + return ( + + ); + }) + : Object.keys(groupedAlerts).map((ruleId: string) => { + const alertsByRule = groupedAlerts[ruleId]; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts new file mode 100644 index 0000000000000..a906744cdafb2 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts @@ -0,0 +1,43 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, size } = euiTheme; + + const container: CSSObject = { + position: 'relative', + }; + + const stickyItem: CSSObject = { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: colors.emptyShade, + paddingTop: size.base, + }; + + const viewMode: CSSObject = { + margin: size.base, + marginBottom: 0, + }; + + return { + container, + stickyItem, + viewMode, + }; + }, [euiTheme]); + + return cached; +}; 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..2b7f78e88fafb 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 @@ -21,12 +21,14 @@ import { processNewEvents, searchProcessTree, autoExpandProcessTree, + updateProcessMap, } from './helpers'; import { sortProcesses } from '../../../common/utils/sort_processes'; interface UseProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; searchQuery?: string; updatedAlertsStatus: AlertStatusEventEntityIdMap; } @@ -196,6 +198,7 @@ export class ProcessImpl implements Process { export const useProcessTree = ({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }: UseProcessTreeDeps) => { @@ -221,6 +224,7 @@ export const useProcessTree = ({ const [processMap, setProcessMap] = useState(initializedProcessMap); const [processedPages, setProcessedPages] = useState([]); + const [alertsProcessed, setAlertsProcessed] = useState(false); const [searchResults, setSearchResults] = useState([]); const [orphans, setOrphans] = useState([]); @@ -257,6 +261,15 @@ export const useProcessTree = ({ } }, [data, processMap, orphans, processedPages, sessionEntityId]); + useEffect(() => { + // currently we are loading a single page of alerts, with no pagination + // so we only need to add these alert events to processMap once. + if (!alertsProcessed) { + updateProcessMap(processMap, alerts); + setAlertsProcessed(true); + } + }, [processMap, alerts, alertsProcessed]); + useEffect(() => { setSearchResults(searchProcessTree(processMap, searchQuery)); autoExpandProcessTree(processMap); 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 9fa7900d04b0d..3c0b9c5d0d4d9 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 @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockData } from '../../../common/mocks/constants/session_view_process.mock'; +import { mockData, mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { Process } from '../../../common/types/process_tree'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { ProcessImpl } from './hooks'; @@ -21,6 +21,7 @@ describe('ProcessTree component', () => { const props: ProcessTreeDeps = { sessionEntityId: sessionLeader.process.entity_id, data: mockData, + alerts: mockAlerts, isFetching: false, fetchNextPage: jest.fn(), hasNextPage: false, @@ -28,7 +29,7 @@ describe('ProcessTree component', () => { hasPreviousPage: false, onProcessSelected: jest.fn(), updatedAlertsStatus: {}, - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { 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 4b489797c7e26..1e10e58d1cca0 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 @@ -26,6 +26,7 @@ export interface ProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; jumpToEvent?: ProcessEvent; isFetching: boolean; @@ -44,8 +45,7 @@ export interface ProcessTreeDeps { // a map for alerts with updated status and process.entity_id updatedAlertsStatus: AlertStatusEventEntityIdMap; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; timeStampOn?: boolean; verboseModeOn?: boolean; } @@ -53,6 +53,7 @@ export interface ProcessTreeDeps { export const ProcessTree = ({ sessionEntityId, data, + alerts, jumpToEvent, isFetching, hasNextPage, @@ -64,8 +65,7 @@ export const ProcessTree = ({ onProcessSelected, setSearchResults, updatedAlertsStatus, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, timeStampOn, verboseModeOn, }: ProcessTreeDeps) => { @@ -76,6 +76,7 @@ export const ProcessTree = ({ const { sessionLeader, processMap, searchResults } = useProcessTree({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }); @@ -203,8 +204,7 @@ export const ProcessTree = ({ selectedProcessId={selectedProcess?.id} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} 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 2a56a0ae2be67..c1b0c807528ec 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 @@ -26,7 +26,7 @@ describe('ProcessTreeAlerts component', () => { isSelected: false, onClick: jest.fn(), selectAlert: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { @@ -61,16 +61,16 @@ describe('ProcessTreeAlerts component', () => { expect(selectAlert).toHaveBeenCalledTimes(1); }); - it('should execute loadAlertDetails callback when clicking on expand button', async () => { - const loadAlertDetails = jest.fn(); + it('should execute onShowAlertDetails callback when clicking on expand button', async () => { + const onShowAlertDetails = jest.fn(); renderResult = mockedContext.render( - + ); const expandButton = renderResult.queryByTestId(EXPAND_BUTTON_TEST_ID); expect(expandButton).toBeTruthy(); expandButton?.click(); - expect(loadAlertDetails).toHaveBeenCalledTimes(1); + expect(onShowAlertDetails).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 5ec1c4a7693c3..30892d02c5428 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 @@ -17,8 +17,7 @@ export interface ProcessTreeAlertDeps { isSelected: boolean; onClick: (alert: ProcessEventAlert | null) => void; selectAlert: (alertUuid: string) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string, status?: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export const ProcessTreeAlert = ({ @@ -27,8 +26,7 @@ export const ProcessTreeAlert = ({ isSelected, onClick, selectAlert, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertDeps) => { const styles = useStyles({ isInvestigated, isSelected }); @@ -41,10 +39,10 @@ export const ProcessTreeAlert = ({ }, [isInvestigated, uuid, selectAlert]); const handleExpandClick = useCallback(() => { - if (loadAlertDetails && uuid) { - loadAlertDetails(uuid, () => handleOnAlertDetailsClosed(uuid)); + if (uuid) { + onShowAlertDetails(uuid); } - }, [handleOnAlertDetailsClosed, loadAlertDetails, uuid]); + }, [onShowAlertDetails, uuid]); const handleClick = useCallback(() => { if (alert.kibana?.alert) { 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 2333c71d36a51..ee6866f6a8a60 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 @@ -17,7 +17,7 @@ describe('ProcessTreeAlerts component', () => { const props: ProcessTreeAlertsDeps = { alerts: mockAlerts, onAlertSelected: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { 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 c97ccfe253605..b51d58bf825ec 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 @@ -16,8 +16,7 @@ export interface ProcessTreeAlertsDeps { jumpToAlertID?: string; isProcessSelected?: boolean; onAlertSelected: (e: MouseEvent) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export function ProcessTreeAlerts({ @@ -25,8 +24,7 @@ export function ProcessTreeAlerts({ jumpToAlertID, isProcessSelected = false, onAlertSelected, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertsDeps) { const [selectedAlert, setSelectedAlert] = useState(null); const styles = useStyles(); @@ -90,8 +88,7 @@ export function ProcessTreeAlerts({ isSelected={isProcessSelected && selectedAlert?.uuid === alertUuid} onClick={handleAlertClick} selectAlert={selectAlert} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} 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 2e82e822f0c82..5c3b790ad0430 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,7 +36,7 @@ describe('ProcessTreeNode component', () => { }, } as unknown as RefObject, onChangeJumpToEventVisibility: jest.fn(), - handleOnAlertDetailsClosed: (_alertUuid: string) => {}, + onShowAlertDetails: jest.fn(), }; 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 b1c42dd95efb9..387e7a5074699 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 @@ -21,7 +21,8 @@ import React, { useMemo, RefObject, } from 'react'; -import { EuiButton, EuiIcon, formatDate } from '@elastic/eui'; +import { EuiButton, EuiIcon, EuiToolTip, formatDate } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { Process } from '../../../common/types/process_tree'; import { useVisible } from '../../hooks/use_visible'; @@ -43,8 +44,7 @@ export interface ProcessDeps { verboseModeOn?: boolean; scrollerRef: RefObject; onChangeJumpToEventVisibility: (isVisible: boolean, isAbove: boolean) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } /** @@ -62,8 +62,7 @@ export function ProcessTreeNode({ verboseModeOn = true, scrollerRef, onChangeJumpToEventVisibility, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessDeps) { const textRef = useRef(null); @@ -144,6 +143,33 @@ export function ProcessTreeNode({ ); const processDetails = process.getDetails(); + const hasExec = process.hasExec(); + + const processIcon = useMemo(() => { + if (!process.parent) { + return 'unlink'; + } else if (hasExec) { + return 'console'; + } else { + return 'branch'; + } + }, [hasExec, process.parent]); + + const iconTooltip = useMemo(() => { + if (!process.parent) { + return i18n.translate('xpack.sessionView.processNode.tooltipOrphan', { + defaultMessage: 'Process missing parent (orphan)', + }); + } else if (hasExec) { + return i18n.translate('xpack.sessionView.processNode.tooltipExec', { + defaultMessage: "Process exec'd", + }); + } else { + return i18n.translate('xpack.sessionView.processNode.tooltipFork', { + defaultMessage: 'Process forked (no exec)', + }); + } + }, [hasExec, process.parent]); if (!processDetails?.process) { return null; @@ -169,11 +195,9 @@ export function ProcessTreeNode({ const showUserEscalation = user.id !== parent.user.id; const interactiveSession = !!tty; const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; - const hasExec = process.hasExec(); const iconTestSubj = hasExec ? 'sessionView:processTreeNodeExecIcon' : 'sessionView:processTreeNodeForkIcon'; - const processIcon = hasExec ? 'console' : 'branch'; const timeStampsNormal = formatDate(start, KIBANA_DATE_FORMAT); @@ -200,7 +224,9 @@ export function ProcessTreeNode({ ) : ( - + + + {' '} {workingDirectory}  {args[0]}  @@ -255,8 +281,7 @@ export function ProcessTreeNode({ jumpToAlertID={jumpToAlertID} isProcessSelected={selectedProcessId === process.id} onAlertSelected={onProcessClicked} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> )} @@ -276,8 +301,7 @@ export function ProcessTreeNode({ verboseModeOn={verboseModeOn} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} 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 a134a366c4168..bf8796336602d 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 @@ -15,9 +15,11 @@ import { ProcessEventResults, } from '../../../common/types/process_tree'; import { + ALERTS_ROUTE, PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, ALERT_STATUS_ROUTE, + QUERY_KEY_PROCESS_EVENTS, QUERY_KEY_ALERTS, } from '../../../common/constants'; @@ -28,9 +30,10 @@ export const useFetchSessionViewProcessEvents = ( const { http } = useKibana().services; const [isJumpToFirstPage, setIsJumpToFirstPage] = useState(false); const jumpToCursor = jumpToEvent && jumpToEvent.process.start; + const cachingKeys = [QUERY_KEY_PROCESS_EVENTS, sessionEntityId]; const query = useInfiniteQuery( - 'sessionViewProcessEvents', + cachingKeys, async ({ pageParam = {} }) => { let { cursor } = pageParam; const { forward } = pageParam; @@ -52,7 +55,7 @@ export const useFetchSessionViewProcessEvents = ( return { events, cursor }; }, { - getNextPageParam: (lastPage, pages) => { + getNextPageParam: (lastPage) => { if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], @@ -60,7 +63,7 @@ export const useFetchSessionViewProcessEvents = ( }; } }, - getPreviousPageParam: (firstPage, pages) => { + getPreviousPageParam: (firstPage) => { if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { cursor: firstPage.events[0]['@timestamp'], @@ -84,6 +87,32 @@ export const useFetchSessionViewProcessEvents = ( return query; }; +export const useFetchSessionViewAlerts = (sessionEntityId: string) => { + const { http } = useKibana().services; + const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId]; + const query = useQuery( + cachingKeys, + async () => { + const res = await http.get(ALERTS_ROUTE, { + query: { + sessionEntityId, + }, + }); + + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return events; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + return query; +}; + export const useFetchAlertStatus = ( updatedAlertsStatus: AlertStatusEventEntityIdMap, alertUuid: string 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 af4eb6114a0a2..ee481c4204108 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 @@ -23,7 +23,11 @@ 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 { useFetchAlertStatus, useFetchSessionViewProcessEvents } from './hooks'; +import { + useFetchAlertStatus, + useFetchSessionViewProcessEvents, + useFetchSessionViewAlerts, +} from './hooks'; /** * The main wrapper component for the session view. @@ -61,8 +65,12 @@ export const SessionView = ({ hasPreviousPage, } = useFetchSessionViewProcessEvents(sessionEntityId, jumpToEvent); - const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; - const renderIsLoading = isFetching && !data; + const alertsQuery = useFetchSessionViewAlerts(sessionEntityId); + const { data: alerts, error: alertsError, isFetching: alertsFetching } = alertsQuery; + + const hasData = alerts && data && data.pages?.[0].events.length > 0; + const hasError = error || alertsError; + const renderIsLoading = (isFetching || alertsFetching) && !data; const renderDetails = isDetailOpen && selectedProcess; const { data: newUpdatedAlertsStatus } = useFetchAlertStatus( updatedAlertsStatus, @@ -83,6 +91,15 @@ export const SessionView = ({ setIsDetailOpen(!isDetailOpen); }, [isDetailOpen]); + const onShowAlertDetails = useCallback( + (alertUuid: string) => { + if (loadAlertDetails) { + loadAlertDetails(alertUuid, () => handleOnAlertDetailsClosed(alertUuid)); + } + }, + [loadAlertDetails, handleOnAlertDetailsClosed] + ); + const handleOptionChange = useCallback((checkedOptions: DisplayOptionsState) => { setDisplayOptions(checkedOptions); }, []); @@ -165,7 +182,7 @@ export const SessionView = ({ )} - {error && ( + {hasError && ( @@ -215,7 +232,7 @@ export const SessionView = ({ {renderDetails ? ( <> - + diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts index d2c87130bfa4b..edfe2356d5aa2 100644 --- a/x-pack/plugins/session_view/public/components/session_view/styles.ts +++ b/x-pack/plugins/session_view/public/components/session_view/styles.ts @@ -17,6 +17,10 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { + const { border, colors } = euiTheme; + + const thinBorder = `${border.width.thin} solid ${colors.lightShade}!important`; + const processTree: CSSObject = { height: `${height}px`, position: 'relative', @@ -24,6 +28,12 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const detailPanel: CSSObject = { height: `${height}px`, + borderLeft: thinBorder, + borderRight: thinBorder, + }; + + const resizeHandle: CSSObject = { + zIndex: 2, }; const searchBar: CSSObject = { @@ -38,6 +48,7 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { return { processTree, detailPanel, + resizeHandle, searchBar, buttonsEyeDetail, }; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx index f754086fe5fab..40e71efd8a6cf 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -6,7 +6,10 @@ */ import React from 'react'; -import { sessionViewBasicProcessMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { + mockAlerts, + sessionViewBasicProcessMock, +} from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { SessionViewDetailPanel } from './index'; @@ -14,27 +17,66 @@ describe('SessionView component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); beforeEach(() => { mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); }); describe('When SessionViewDetailPanel is mounted', () => { it('shows process detail by default', async () => { renderResult = mockedContext.render( - + ); expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); }); it('can switch tabs to show host details', async () => { renderResult = mockedContext.render( - + ); renderResult.queryByText('Host')?.click(); expect(renderResult.queryByText('hostname')).toBeVisible(); expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); }); + + it('can switch tabs to show alert details', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeVisible(); + }); + it('alert tab disabled when no alerts', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx index a47ce1d91ac97..51eb65a38f835 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -6,50 +6,91 @@ */ import React, { useState, useMemo, useCallback } from 'react'; import { EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { EuiTabProps } from '../../types'; -import { Process } from '../../../common/types/process_tree'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; import { getDetailPanelProcess, getSelectedTabContent } from './helpers'; import { DetailPanelProcessTab } from '../detail_panel_process_tab'; import { DetailPanelHostTab } from '../detail_panel_host_tab'; +import { DetailPanelAlertTab } from '../detail_panel_alert_tab'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; interface SessionViewDetailPanelDeps { selectedProcess: Process; - onProcessSelected?: (process: Process) => void; + alerts?: ProcessEvent[]; + investigatedAlert?: ProcessEvent; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; } /** * Detail panel in the session view. */ -export const SessionViewDetailPanel = ({ selectedProcess }: SessionViewDetailPanelDeps) => { +export const SessionViewDetailPanel = ({ + alerts, + selectedProcess, + investigatedAlert, + onProcessSelected, + onShowAlertDetails, +}: SessionViewDetailPanelDeps) => { const [selectedTabId, setSelectedTabId] = useState('process'); const processDetail = useMemo(() => getDetailPanelProcess(selectedProcess), [selectedProcess]); - const tabs: EuiTabProps[] = useMemo( - () => [ + const alertsCount = useMemo(() => { + if (!alerts) { + return 0; + } + + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + const tabs: EuiTabProps[] = useMemo(() => { + const hasAlerts = !!alerts?.length; + + return [ { id: 'process', - name: 'Process', + name: i18n.translate('xpack.sessionView.detailsPanel.process', { + defaultMessage: 'Process', + }), content: , }, { id: 'host', - name: 'Host', + name: i18n.translate('xpack.sessionView.detailsPanel.host', { + defaultMessage: 'Host', + }), content: , }, { id: 'alerts', - disabled: true, - name: 'Alerts', - append: ( + name: i18n.translate('xpack.sessionView.detailsPanel.alerts', { + defaultMessage: 'Alerts', + }), + append: hasAlerts && ( - 10 + {alertsCount} ), - content: null, + content: alerts && ( + + ), }, - ], - [processDetail, selectedProcess.events] - ); + ]; + }, [ + alerts, + alertsCount, + processDetail, + selectedProcess.events, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, + ]); const onSelectedTabChanged = useCallback((id: string) => { setSelectedTabId(id); diff --git a/x-pack/plugins/session_view/server/plugin.ts b/x-pack/plugins/session_view/server/plugin.ts index c7fd511b3de05..7347f7676af62 100644 --- a/x-pack/plugins/session_view/server/plugin.ts +++ b/x-pack/plugins/session_view/server/plugin.ts @@ -11,12 +11,14 @@ import { Plugin, Logger, PluginInitializerContext, + IRouter, } from '../../../../src/core/server'; import { SessionViewSetupPlugins, SessionViewStartPlugins } from './types'; import { registerRoutes } from './routes'; export class SessionViewPlugin implements Plugin { private logger: Logger; + private router: IRouter | undefined; /** * Initialize SessionViewPlugin class properties (logger, etc) that is accessible @@ -28,14 +30,16 @@ export class SessionViewPlugin implements Plugin { public setup(core: CoreSetup, plugins: SessionViewSetupPlugins) { this.logger.debug('session view: Setup'); - const router = core.http.createRouter(); - - // Register server routes - registerRoutes(router); + this.router = core.http.createRouter(); } public start(core: CoreStart, plugins: SessionViewStartPlugins) { this.logger.debug('session view: Start'); + + // Register server routes + if (this.router) { + registerRoutes(this.router, plugins.ruleRegistry); + } } public stop() { diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.test.ts b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts new file mode 100644 index 0000000000000..4c8ee6fb2c83e --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { + ALERT_RULE_CONSUMER, + ALERT_RULE_TYPE_ID, + SPACE_IDS, + ALERT_WORKFLOW_STATUS, +} from '@kbn/rule-data-utils'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { doSearch } from './alerts_route'; +import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; + +import { + AlertsClient, + ConstructorOptions, +} from '../../../rule_registry/server/alert_data_client/alerts_client'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { alertingAuthorizationMock } from '../../../alerting/server/authorization/alerting_authorization.mock'; +import { auditLoggerMock } from '../../../security/server/audit/mocks'; +import { AlertingAuthorizationEntity } from '../../../alerting/server'; +import { ruleDataServiceMock } from '../../../rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock'; + +const alertingAuthMock = alertingAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const DEFAULT_SPACE = 'test_default_space_id'; + +const getEmptyResponse = async () => { + return { + hits: { + total: 0, + hits: [], + }, + }; +}; + +const getResponse = async () => { + return { + hits: { + total: mockEvents.length, + hits: mockEvents.map((event) => { + return { + found: true, + _type: 'alert', + _index: '.alerts-security', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + [ALERT_RULE_TYPE_ID]: 'apm.error_rate', + message: 'hello world 1', + [ALERT_RULE_CONSUMER]: 'apm', + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['test_default_space_id'], + ...event, + }, + }; + }), + }, + }; +}; + +const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + +const alertsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + authorization: alertingAuthMock, + auditLogger, + ruleDataService: ruleDataServiceMock.create(), + esClient: esClientMock, +}; + +describe('alerts_route.ts', () => { + beforeEach(() => { + jest.resetAllMocks(); + + alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE); + // @ts-expect-error + alertingAuthMock.getAuthorizationFilter.mockImplementation(async () => + Promise.resolve({ filter: [] }) + ); + // @ts-expect-error + alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => { + const authorizedRuleTypes = new Set(); + authorizedRuleTypes.add({ producer: 'apm' }); + return Promise.resolve({ authorizedRuleTypes }); + }); + + alertingAuthMock.ensureAuthorized.mockImplementation( + // @ts-expect-error + async ({ + ruleTypeId, + consumer, + operation, + entity, + }: { + ruleTypeId: string; + consumer: string; + operation: string; + entity: typeof AlertingAuthorizationEntity.Alert; + }) => { + if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') { + return Promise.resolve(); + } + return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`)); + } + ); + }); + + describe('doSearch(client, sessionEntityId)', () => { + it('should return an empty events array for a non existant entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(0); + }); + + it('returns results for a particular session entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(mockEvents.length); + }); + }); +}); diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.ts b/x-pack/plugins/session_view/server/routes/alerts_route.ts new file mode 100644 index 0000000000000..3d03cb5cb8214 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.ts @@ -0,0 +1,66 @@ +/* + * 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 { IRouter } from '../../../../../src/core/server'; +import { + ALERTS_ROUTE, + ALERTS_PER_PAGE, + ENTRY_SESSION_ENTITY_ID_PROPERTY, +} from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; +import type { AlertsClient, RuleRegistryPluginStartContract } from '../../../rule_registry/server'; + +export const registerAlertsRoute = ( + router: IRouter, + ruleRegistry: RuleRegistryPluginStartContract +) => { + router.get( + { + path: ALERTS_ROUTE, + validate: { + query: schema.object({ + sessionEntityId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = await ruleRegistry.getRacClientWithRequest(request); + const { sessionEntityId } = request.query; + const body = await doSearch(client, sessionEntityId); + + return response.ok({ body }); + } + ); +}; + +export const doSearch = async (client: AlertsClient, sessionEntityId: string) => { + const indices = await client.getAuthorizedAlertsIndices(['siem']); + + if (!indices) { + return { events: [] }; + } + + const results = await client.find({ + query: { + match: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + track_total_hits: false, + size: ALERTS_PER_PAGE, + index: indices.join(','), + }); + + const events = results.hits.hits.map((hit: any) => { + // the alert indexes flattens many properties. this util unflattens them as session view expects structured json. + 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 b8cb80dc1d1d4..17efeb5d07a7b 100644 --- a/x-pack/plugins/session_view/server/routes/index.ts +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -6,11 +6,14 @@ */ import { IRouter } from '../../../../../src/core/server'; import { registerProcessEventsRoute } from './process_events_route'; +import { registerAlertsRoute } from './alerts_route'; import { registerAlertStatusRoute } from './alert_status_route'; import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; +import { RuleRegistryPluginStartContract } from '../../../rule_registry/server'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => { registerProcessEventsRoute(router); registerAlertStatusRoute(router); sessionEntryLeadersRoute(router); + registerAlertsRoute(router, ruleRegistry); }; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 47e2d917733d5..7be1885c70ab1 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -11,10 +11,8 @@ import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, PROCESS_EVENTS_INDEX, - ALERTS_INDEX, ENTRY_SESSION_ENTITY_ID_PROPERTY, } from '../../common/constants'; -import { expandDottedObject } from '../../common/utils/expand_dotted_object'; export const registerProcessEventsRoute = (router: IRouter) => { router.get( @@ -45,35 +43,25 @@ export const doSearch = async ( forward = true ) => { const search = await client.search({ - // TODO: move alerts into it's own route with it's own pagination. - index: [PROCESS_EVENTS_INDEX, ALERTS_INDEX], - ignore_unavailable: true, + index: [PROCESS_EVENTS_INDEX], body: { query: { match: { [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, }, }, - // This runtime_mappings is a temporary fix, so we are able to Query these ECS fields while they are not available - // TODO: Remove the runtime_mappings once process.entry_leader.entity_id is implemented to ECS - runtime_mappings: { - [ENTRY_SESSION_ENTITY_ID_PROPERTY]: { - type: 'keyword', - }, - }, size: PROCESS_EVENTS_PER_PAGE, - sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], + // we first sort by process.start, this allows lifecycle events to load all at once for a given process, and + // avoid issues like where the session leaders 'end' event is loaded at the very end of what could be multiple pages of events + sort: [ + { 'process.start': forward ? 'asc' : 'desc' }, + { '@timestamp': forward ? 'asc' : 'desc' }, + ], search_after: cursor ? [cursor] : undefined, }, }); - const events = search.hits.hits.map((hit: any) => { - // TODO: re-eval if this is needed after moving alerts to it's own route. - // the .siem-signals-default index flattens many properties. this util unflattens them. - hit._source = expandDottedObject(hit._source); - - return hit; - }); + const events = search.hits.hits; if (!forward) { events.reverse(); diff --git a/x-pack/plugins/session_view/server/types.ts b/x-pack/plugins/session_view/server/types.ts index 0d1375081ca87..29995077ccfbe 100644 --- a/x-pack/plugins/session_view/server/types.ts +++ b/x-pack/plugins/session_view/server/types.ts @@ -4,8 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { + RuleRegistryPluginSetupContract as RuleRegistryPluginSetup, + RuleRegistryPluginStartContract as RuleRegistryPluginStart, +} from '../../rule_registry/server'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewSetupPlugins {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewStartPlugins {} +export interface SessionViewSetupPlugins { + ruleRegistry: RuleRegistryPluginSetup; +} + +export interface SessionViewStartPlugins { + ruleRegistry: RuleRegistryPluginStart; +} diff --git a/x-pack/plugins/session_view/tsconfig.json b/x-pack/plugins/session_view/tsconfig.json index a99e83976a31d..0a21d320dfb29 100644 --- a/x-pack/plugins/session_view/tsconfig.json +++ b/x-pack/plugins/session_view/tsconfig.json @@ -37,6 +37,7 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../infra/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../rule_registry/tsconfig.json" } ] }