From ccb3a9779877ec666519059474d9a34e94744506 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 12 Aug 2021 13:39:32 -0400 Subject: [PATCH] [Security solution] [RAC] Add row renderer popover to alert table "reason" field (#108054) (#108385) * Add row renderer popover to alert table reason field * Add a title to row renderer popover on alert table * Fix issues found during code review Co-authored-by: Pablo Machado --- .../render_cell_value.tsx | 6 + .../row_renderers_browser/catalog/index.tsx | 52 ++++-- .../body/renderers/column_renderer.ts | 7 +- .../timeline/body/renderers/constants.tsx | 1 + .../timeline/body/renderers/index.ts | 2 + .../renderers/reason_column_renderer.test.tsx | 148 ++++++++++++++++ .../body/renderers/reason_column_renderer.tsx | 164 ++++++++++++++++++ .../timeline/body/renderers/translations.ts | 6 + .../cell_rendering/default_cell_renderer.tsx | 6 + .../common/types/timeline/cells/index.ts | 7 +- .../public/components/t_grid/body/index.tsx | 5 +- 11 files changed, 385 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.tsx diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 72914507bb6a6..46fb853a7aa29 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -31,6 +31,9 @@ export const RenderCellValue: React.FC< rowIndex, setCellProps, timelineId, + ecsData, + rowRenderers, + browserFields, }) => ( ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/catalog/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/catalog/index.tsx index f724c19913c8e..548dadf21b78b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/catalog/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/catalog/index.tsx @@ -40,6 +40,26 @@ const Link = ({ children, url }: { children: React.ReactNode; url: string }) => ); +export const eventRendererNames: { [key in RowRendererId]: string } = { + [RowRendererId.alerts]: i18n.ALERTS_NAME, + [RowRendererId.auditd]: i18n.AUDITD_NAME, + [RowRendererId.auditd_file]: i18n.AUDITD_FILE_NAME, + [RowRendererId.library]: i18n.LIBRARY_NAME, + [RowRendererId.system_security_event]: i18n.AUTHENTICATION_NAME, + [RowRendererId.system_dns]: i18n.DNS_NAME, + [RowRendererId.netflow]: i18n.FLOW_NAME, + [RowRendererId.system]: i18n.SYSTEM_NAME, + [RowRendererId.system_endgame_process]: i18n.PROCESS, + [RowRendererId.registry]: i18n.REGISTRY_NAME, + [RowRendererId.system_fim]: i18n.FIM_NAME, + [RowRendererId.system_file]: i18n.FILE_NAME, + [RowRendererId.system_socket]: i18n.SOCKET_NAME, + [RowRendererId.suricata]: 'Suricata', + [RowRendererId.threat_match]: i18n.THREAT_MATCH_NAME, + [RowRendererId.zeek]: i18n.ZEEK_NAME, + [RowRendererId.plain]: '', +}; + export interface RowRendererOption { id: RowRendererId; name: string; @@ -51,14 +71,14 @@ export interface RowRendererOption { export const renderers: RowRendererOption[] = [ { id: RowRendererId.alerts, - name: i18n.ALERTS_NAME, + name: eventRendererNames[RowRendererId.alerts], description: i18n.ALERTS_DESCRIPTION, example: AlertsExample, searchableDescription: i18n.ALERTS_DESCRIPTION, }, { id: RowRendererId.auditd, - name: i18n.AUDITD_NAME, + name: eventRendererNames[RowRendererId.auditd], description: ( @@ -72,7 +92,7 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.auditd_file, - name: i18n.AUDITD_FILE_NAME, + name: eventRendererNames[RowRendererId.auditd_file], description: ( @@ -86,14 +106,14 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.library, - name: i18n.LIBRARY_NAME, + name: eventRendererNames[RowRendererId.library], description: i18n.LIBRARY_DESCRIPTION, example: LibraryExample, searchableDescription: i18n.LIBRARY_DESCRIPTION, }, { id: RowRendererId.system_security_event, - name: i18n.AUTHENTICATION_NAME, + name: eventRendererNames[RowRendererId.system_security_event], description: (

{i18n.AUTHENTICATION_DESCRIPTION_PART1}

@@ -106,14 +126,14 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.system_dns, - name: i18n.DNS_NAME, + name: eventRendererNames[RowRendererId.system_dns], description: i18n.DNS_DESCRIPTION_PART1, example: SystemDnsExample, searchableDescription: i18n.DNS_DESCRIPTION_PART1, }, { id: RowRendererId.netflow, - name: i18n.FLOW_NAME, + name: eventRendererNames[RowRendererId.netflow], description: (

{i18n.FLOW_DESCRIPTION_PART1}

@@ -126,7 +146,7 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.system, - name: i18n.SYSTEM_NAME, + name: eventRendererNames[RowRendererId.system], description: (

@@ -145,7 +165,7 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.system_endgame_process, - name: i18n.PROCESS, + name: eventRendererNames[RowRendererId.system_endgame_process], description: (

{i18n.PROCESS_DESCRIPTION_PART1}

@@ -158,28 +178,28 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.registry, - name: i18n.REGISTRY_NAME, + name: eventRendererNames[RowRendererId.registry], description: i18n.REGISTRY_DESCRIPTION, example: RegistryExample, searchableDescription: i18n.REGISTRY_DESCRIPTION, }, { id: RowRendererId.system_fim, - name: i18n.FIM_NAME, + name: eventRendererNames[RowRendererId.system_fim], description: i18n.FIM_DESCRIPTION_PART1, example: SystemFimExample, searchableDescription: i18n.FIM_DESCRIPTION_PART1, }, { id: RowRendererId.system_file, - name: i18n.FILE_NAME, + name: eventRendererNames[RowRendererId.system_file], description: i18n.FILE_DESCRIPTION_PART1, example: SystemFileExample, searchableDescription: i18n.FILE_DESCRIPTION_PART1, }, { id: RowRendererId.system_socket, - name: i18n.SOCKET_NAME, + name: eventRendererNames[RowRendererId.system_socket], description: (

{i18n.SOCKET_DESCRIPTION_PART1}

@@ -192,7 +212,7 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.suricata, - name: 'Suricata', + name: eventRendererNames[RowRendererId.suricata], description: (

{i18n.SURICATA_DESCRIPTION_PART1}{' '} @@ -207,14 +227,14 @@ export const renderers: RowRendererOption[] = [ }, { id: RowRendererId.threat_match, - name: i18n.THREAT_MATCH_NAME, + name: eventRendererNames[RowRendererId.threat_match], description: i18n.THREAT_MATCH_DESCRIPTION, example: ThreatMatchExample, searchableDescription: `${i18n.THREAT_MATCH_NAME} ${i18n.THREAT_MATCH_DESCRIPTION}`, }, { id: RowRendererId.zeek, - name: i18n.ZEEK_NAME, + name: eventRendererNames[RowRendererId.zeek], description: (

{i18n.ZEEK_DESCRIPTION_PART1}{' '} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts index fc13680b81be2..1e6f613999ece 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts @@ -6,7 +6,9 @@ */ import type React from 'react'; -import { ColumnHeaderOptions } from '../../../../../../common'; + +import { BrowserFields, ColumnHeaderOptions, RowRenderer } from '../../../../../../common'; +import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; export interface ColumnRenderer { @@ -29,5 +31,8 @@ export interface ColumnRenderer { truncate?: boolean; values: string[] | null | undefined; linkValues?: string[] | null | undefined; + ecsData?: Ecs; + rowRenderers?: RowRenderer[]; + browserFields?: BrowserFields; }) => React.ReactNode; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx index aeb40bed26c8e..3a7a43da2aedc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx @@ -17,3 +17,4 @@ export const EVENT_URL_FIELD_NAME = 'event.url'; export const SIGNAL_RULE_NAME_FIELD_NAME = 'signal.rule.name'; export const SIGNAL_STATUS_FIELD_NAME = 'signal.status'; export const AGENT_STATUS_FIELD_NAME = 'agent.status'; +export const REASON_FIELD_NAME = 'signal.reason'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts index 911dcc8cd2e87..11c501f9426f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts @@ -16,6 +16,7 @@ import { unknownColumnRenderer } from './unknown_column_renderer'; import { zeekRowRenderer } from './zeek/zeek_row_renderer'; import { systemRowRenderers } from './system/generic_row_renderer'; import { threatMatchRowRenderer } from './cti/threat_match_row_renderer'; +import { reasonColumnRenderer } from './reason_column_renderer'; // The row renderers are order dependent and will return the first renderer // which returns true from its isInstance call. The bottom renderers which @@ -34,6 +35,7 @@ export const defaultRowRenderers: RowRenderer[] = [ ]; export const columnRenderers: ColumnRenderer[] = [ + reasonColumnRenderer, plainColumnRenderer, emptyColumnRenderer, unknownColumnRenderer, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx new file mode 100644 index 0000000000000..addb991af58d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 { mockTimelineData } from '../../../../../common/mock'; +import { defaultColumnHeaderType } from '../column_headers/default_headers'; +import { REASON_FIELD_NAME } from './constants'; +import { reasonColumnRenderer } from './reason_column_renderer'; +import { plainColumnRenderer } from './plain_column_renderer'; + +import { + BrowserFields, + ColumnHeaderOptions, + RowRenderer, + RowRendererId, +} from '../../../../../../common'; +import { fireEvent, render } from '@testing-library/react'; +import { TestProviders } from '../../../../../../../timelines/public/mock'; +import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../../timelines/public/components'; +import { cloneDeep } from 'lodash'; +jest.mock('./plain_column_renderer'); + +jest.mock('../../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: () => ({ + services: { + timelines: { + getUseDraggableKeyboardWrapper: () => mockUseDraggableKeyboardWrapper, + }, + }, + }), + }; +}); + +jest.mock('../../../../../common/components/link_to', () => { + const original = jest.requireActual('../../../../../common/components/link_to'); + return { + ...original, + useFormatUrl: () => ({ + formatUrl: () => '', + }), + }; +}); + +const invalidEcs = cloneDeep(mockTimelineData[0].ecs); +const validEcs = cloneDeep(mockTimelineData[28].ecs); + +const field: ColumnHeaderOptions = { + id: 'test-field-id', + columnHeaderType: defaultColumnHeaderType, +}; + +const rowRenderers: RowRenderer[] = [ + { + id: RowRendererId.alerts, + isInstance: (ecs) => ecs === validEcs, + // eslint-disable-next-line react/display-name + renderRow: () => , + }, +]; +const browserFields: BrowserFields = {}; + +const defaultProps = { + columnName: REASON_FIELD_NAME, + eventId: 'test-event-id', + field, + timelineId: 'test-timeline-id', + values: ['test-value'], +}; + +describe('reasonColumnRenderer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('isIntance', () => { + it('returns true when columnName is `signal.reason`', () => { + expect(reasonColumnRenderer.isInstance(REASON_FIELD_NAME, [])).toBeTruthy(); + }); + }); + + describe('renderColumn', () => { + it('calls `plainColumnRenderer.renderColumn` when ecsData, rowRenderers or browserFields is empty', () => { + reasonColumnRenderer.renderColumn(defaultProps); + + expect(plainColumnRenderer.renderColumn).toBeCalledTimes(1); + }); + + it("doesn't call `plainColumnRenderer.renderColumn` when ecsData, rowRenderers or browserFields fields are not empty", () => { + reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: invalidEcs, + rowRenderers, + browserFields, + }); + + expect(plainColumnRenderer.renderColumn).toBeCalledTimes(0); + }); + + it("doesn't render popover button when getRowRenderer doesn't find a rowRenderer", () => { + const renderedColumn = reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: invalidEcs, + rowRenderers, + browserFields, + }); + + const wrapper = render({renderedColumn}); + + expect(wrapper.queryByTestId('reason-cell-button')).not.toBeInTheDocument(); + }); + + it('render popover button when getRowRenderer finds a rowRenderer', () => { + const renderedColumn = reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: validEcs, + rowRenderers, + browserFields, + }); + + const wrapper = render({renderedColumn}); + + expect(wrapper.queryByTestId('reason-cell-button')).toBeInTheDocument(); + }); + + it('render rowRender inside a popover when reson field button is clicked', () => { + const renderedColumn = reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: validEcs, + rowRenderers, + browserFields, + }); + + const wrapper = render({renderedColumn}); + + fireEvent.click(wrapper.getByTestId('reason-cell-button')); + + expect(wrapper.queryByTestId('test-row-render')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.tsx new file mode 100644 index 0000000000000..0914c861d00ed --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.tsx @@ -0,0 +1,164 @@ +/* + * 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 { EuiButtonEmpty, EuiPopover, EuiPopoverTitle } from '@elastic/eui'; +import { isEqual } from 'lodash/fp'; +import React, { useCallback, useMemo, useState } from 'react'; + +import styled from 'styled-components'; +import { BrowserFields, ColumnHeaderOptions, RowRenderer } from '../../../../../../common'; +import { Ecs } from '../../../../../../common/ecs'; +import { DefaultDraggable } from '../../../../../common/components/draggables'; +import { eventRendererNames } from '../../../row_renderers_browser/catalog'; +import { ColumnRenderer } from './column_renderer'; +import { REASON_FIELD_NAME } from './constants'; +import { getRowRenderer } from './get_row_renderer'; +import { plainColumnRenderer } from './plain_column_renderer'; +import * as i18n from './translations'; + +export const reasonColumnRenderer: ColumnRenderer = { + isInstance: isEqual(REASON_FIELD_NAME), + + renderColumn: ({ + columnName, + eventId, + field, + isDraggable = true, + timelineId, + truncate, + values, + linkValues, + ecsData, + rowRenderers = [], + browserFields, + }: { + columnName: string; + eventId: string; + field: ColumnHeaderOptions; + isDraggable?: boolean; + timelineId: string; + truncate?: boolean; + values: string[] | undefined | null; + linkValues?: string[] | null | undefined; + + ecsData?: Ecs; + rowRenderers?: RowRenderer[]; + browserFields?: BrowserFields; + }) => + values != null && ecsData && rowRenderers?.length > 0 && browserFields + ? values.map((value, i) => ( + + )) + : plainColumnRenderer.renderColumn({ + columnName, + eventId, + field, + isDraggable, + timelineId, + truncate, + values, + linkValues, + }), +}; + +const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` + font-weight: ${(props) => props.theme.eui.euiFontWeightRegular}; +`; + +const ReasonCell: React.FC<{ + contextId: string; + eventId: string; + fieldName: string; + isDraggable?: boolean; + value: string | number | undefined | null; + timelineId: string; + ecsData: Ecs; + rowRenderers: RowRenderer[]; + browserFields: BrowserFields; +}> = ({ + ecsData, + rowRenderers, + browserFields, + timelineId, + value, + fieldName, + isDraggable, + contextId, + eventId, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const rowRenderer = useMemo(() => getRowRenderer(ecsData, rowRenderers), [ecsData, rowRenderers]); + + const rowRender = useMemo(() => { + return ( + rowRenderer && + rowRenderer.renderRow({ + browserFields, + data: ecsData, + isDraggable: true, + timelineId, + }) + ); + }, [rowRenderer, browserFields, ecsData, timelineId]); + + const handleTogglePopOver = useCallback(() => setIsOpen(!isOpen), [setIsOpen, isOpen]); + const handleClosePopOver = useCallback(() => setIsOpen(false), [setIsOpen]); + + const button = useMemo( + () => ( + + {value} + + ), + [value, handleTogglePopOver] + ); + + return ( + <> + + {rowRenderer && rowRender ? ( + + + {i18n.EVENT_RENDERER_POPOVER_TITLE(eventRendererNames[rowRenderer.id] ?? '')} + + {rowRender} + + ) : ( + value + )} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts index d00148d41f3f6..a703c7afdaf7b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts @@ -51,3 +51,9 @@ export const EMPTY_STATUS = i18n.translate( defaultMessage: '-', } ); + +export const EVENT_RENDERER_POPOVER_TITLE = (eventRendererName: string) => + i18n.translate('xpack.securitySolution.event.reason.eventRenderPopoverTitle', { + values: { eventRendererName }, + defaultMessage: 'Event renderer: {eventRendererName} ', + }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx index d2652ed063fc7..d45c8103d1cca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx @@ -22,6 +22,9 @@ export const DefaultCellRenderer: React.FC = ({ linkValues, setCellProps, timelineId, + rowRenderers, + browserFields, + ecsData, }) => ( <> {getColumnRenderer(header.id, columnRenderers, data).renderColumn({ @@ -36,6 +39,9 @@ export const DefaultCellRenderer: React.FC = ({ data, fieldName: header.id, }), + rowRenderers, + browserFields, + ecsData, })} ); diff --git a/x-pack/plugins/timelines/common/types/timeline/cells/index.ts b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts index 2a6e1b3e12bcf..354a8b45e914d 100644 --- a/x-pack/plugins/timelines/common/types/timeline/cells/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts @@ -6,7 +6,9 @@ */ import { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { TimelineNonEcsData } from '../../../search_strategy'; +import { RowRenderer } from '../../..'; +import { Ecs } from '../../../ecs'; +import { BrowserFields, TimelineNonEcsData } from '../../../search_strategy'; import { ColumnHeaderOptions } from '../columns'; /** The following props are provided to the function called by `renderCellValue` */ @@ -19,4 +21,7 @@ export type CellValueElementProps = EuiDataGridCellValueElementProps & { timelineId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any setFlyoutAlert?: (data: any) => void; + ecsData?: Ecs; + rowRenderers?: RowRenderer[]; + browserFields?: BrowserFields; }; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index cc94f901446a7..5fba7cff55e5c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -526,9 +526,12 @@ export const BodyComponent = React.memo( rowIndex, setCellProps, timelineId: tabType != null ? `${id}-${tabType}` : id, + ecsData: data[rowIndex].ecs, + browserFields, + rowRenderers, }); }, - [columnHeaders, data, id, renderCellValue, tabType, theme] + [columnHeaders, data, id, renderCellValue, tabType, theme, browserFields, rowRenderers] ); return (