[Security solution] [RAC] Add row renderer popover to alert table "reason" field (#108054)

* 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
This commit is contained in:
Pablo Machado 2021-08-12 17:11:53 +02:00 committed by GitHub
parent 3950c436a7
commit 79fefe0311
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 385 additions and 19 deletions

View file

@ -31,6 +31,9 @@ export const RenderCellValue: React.FC<
rowIndex,
setCellProps,
timelineId,
ecsData,
rowRenderers,
browserFields,
}) => (
<DefaultCellRenderer
columnId={columnId}
@ -45,5 +48,8 @@ export const RenderCellValue: React.FC<
rowIndex={rowIndex}
setCellProps={setCellProps}
timelineId={timelineId}
ecsData={ecsData}
rowRenderers={rowRenderers}
browserFields={browserFields}
/>
);

View file

@ -40,6 +40,26 @@ const Link = ({ children, url }: { children: React.ReactNode; url: string }) =>
</EuiLink>
);
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: (
<span>
<Link url="https://www.elastic.co/guide/en/beats/auditbeat/current/auditbeat-module-auditd.html">
@ -72,7 +92,7 @@ export const renderers: RowRendererOption[] = [
},
{
id: RowRendererId.auditd_file,
name: i18n.AUDITD_FILE_NAME,
name: eventRendererNames[RowRendererId.auditd_file],
description: (
<span>
<Link url="https://www.elastic.co/guide/en/beats/auditbeat/current/auditbeat-module-auditd.html">
@ -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: (
<div>
<p>{i18n.AUTHENTICATION_DESCRIPTION_PART1}</p>
@ -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: (
<div>
<p>{i18n.FLOW_DESCRIPTION_PART1}</p>
@ -126,7 +146,7 @@ export const renderers: RowRendererOption[] = [
},
{
id: RowRendererId.system,
name: i18n.SYSTEM_NAME,
name: eventRendererNames[RowRendererId.system],
description: (
<div>
<p>
@ -145,7 +165,7 @@ export const renderers: RowRendererOption[] = [
},
{
id: RowRendererId.system_endgame_process,
name: i18n.PROCESS,
name: eventRendererNames[RowRendererId.system_endgame_process],
description: (
<div>
<p>{i18n.PROCESS_DESCRIPTION_PART1}</p>
@ -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: (
<div>
<p>{i18n.SOCKET_DESCRIPTION_PART1}</p>
@ -192,7 +212,7 @@ export const renderers: RowRendererOption[] = [
},
{
id: RowRendererId.suricata,
name: 'Suricata',
name: eventRendererNames[RowRendererId.suricata],
description: (
<p>
{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: (
<p>
{i18n.ZEEK_DESCRIPTION_PART1}{' '}

View file

@ -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;
}

View file

@ -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';

View file

@ -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,

View file

@ -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: () => <span data-test-subj="test-row-render" />,
},
];
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(<TestProviders>{renderedColumn}</TestProviders>);
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(<TestProviders>{renderedColumn}</TestProviders>);
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(<TestProviders>{renderedColumn}</TestProviders>);
fireEvent.click(wrapper.getByTestId('reason-cell-button'));
expect(wrapper.queryByTestId('test-row-render')).toBeInTheDocument();
});
});
});

View file

@ -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) => (
<ReasonCell
key={`reason-column-renderer-value-${timelineId}-${columnName}-${eventId}-${field.id}-${value}-${i}`}
contextId={`reason-column-renderer-${timelineId}`}
timelineId={timelineId}
eventId={eventId}
value={value}
isDraggable={isDraggable}
fieldName={columnName}
ecsData={ecsData}
rowRenderers={rowRenderers}
browserFields={browserFields}
/>
))
: 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(
() => (
<StyledEuiButtonEmpty
data-test-subj="reason-cell-button"
size="xs"
flush="left"
onClick={handleTogglePopOver}
>
{value}
</StyledEuiButtonEmpty>
),
[value, handleTogglePopOver]
);
return (
<>
<DefaultDraggable
field={fieldName}
id={`reason-column-draggable-${contextId}-${eventId}-${fieldName}-${value}`}
isDraggable={isDraggable}
value={`${value}`}
tooltipContent={value}
>
{rowRenderer && rowRender ? (
<EuiPopover
isOpen={isOpen}
anchorPosition="rightCenter"
closePopover={handleClosePopOver}
button={button}
>
<EuiPopoverTitle paddingSize="s">
{i18n.EVENT_RENDERER_POPOVER_TITLE(eventRendererNames[rowRenderer.id] ?? '')}
</EuiPopoverTitle>
{rowRender}
</EuiPopover>
) : (
value
)}
</DefaultDraggable>
</>
);
};

View file

@ -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} ',
});

View file

@ -22,6 +22,9 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
linkValues,
setCellProps,
timelineId,
rowRenderers,
browserFields,
ecsData,
}) => (
<>
{getColumnRenderer(header.id, columnRenderers, data).renderColumn({
@ -36,6 +39,9 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
data,
fieldName: header.id,
}),
rowRenderers,
browserFields,
ecsData,
})}
</>
);

View file

@ -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;
};

View file

@ -526,9 +526,12 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
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 (