[Security Solution] Alert Renderer (#140825)

## [Security Solution] Alert Renderer

This PR introduces the new _Alert Renderer_, an interactive version of the `kibana.alert.reason` field.

Every alert is now rendered by the new Alert Renderer in:

### Timeline

![alert_renderer_in_timeline](https://user-images.githubusercontent.com/4459398/190467602-95436561-5f30-475a-be1c-67cbc3b900b7.png)

_Above: The Alert Renderer in Timeline_

### The Alerts page's _Event rendered view_

![event_rendered_view](https://user-images.githubusercontent.com/4459398/190413436-25aa39f9-9897-4b26-b0ad-31b43b4527d8.png)

_Above: The Alert Renderer in the Alert page's Event rendered view_

### The Alert details flyout

![alert_details_flyout](https://user-images.githubusercontent.com/4459398/190427006-75b48548-d81a-48cb-a034-15df7f3e4a86.png)

_Above: The Alert Renderer in the Alert details flyout_

### The _Reason_ column popover in the Alerts page's _Grid view_

![reason_popover](https://user-images.githubusercontent.com/4459398/190424383-65e89635-845c-49b7-9d35-34da90e4b185.png)

_Above: The Alert Renderer in the Reason column popover_

### What happens if an alert also has an event renderer, like a file or process event?

We combine the new Alert Renderer with other event renderers, for example, the process renderer, to display both whenever we can.

### In the Alerts and Rule details pages, do I need to switch to the _Event rendered view_ every time the page is refreshed?

Not anymore, because the _Grid view_ / _Event rendered view_ selection is persisted to local storage.

![view_selection](https://user-images.githubusercontent.com/4459398/190423682-3fcfd3ae-d63a-4c19-9f5b-6d9142aaef7e.png)

_Above: View selection is now persisted in local storage_
This commit is contained in:
Andrew Goldstein 2022-09-19 15:10:25 -06:00 committed by GitHub
parent e7b4063057
commit 2db0664ecb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1962 additions and 126 deletions

View file

@ -19,6 +19,7 @@ export interface SignalEcs {
export type SignalEcsAAD = Exclude<SignalEcs, 'rule' | 'status'> & {
rule?: Exclude<RuleEcs, 'id'> & { parameters: Record<string, unknown>; uuid: string[] };
severity?: string[];
building_block_type?: string[];
workflow_status?: string[];
};

View file

@ -205,6 +205,9 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf<
>;
export enum RowRendererId {
/** event.kind: signal */
alert = 'alert',
/** endpoint alerts (created on the endpoint) */
alerts = 'alerts',
auditd = 'auditd',
auditd_file = 'auditd_file',

View file

@ -207,6 +207,7 @@ const DraggableBadgeComponent: React.FC<BadgeDraggableType> = ({
name,
color = 'hollow',
children,
timelineId,
tooltipContent,
queryValue,
}) =>
@ -219,6 +220,7 @@ const DraggableBadgeComponent: React.FC<BadgeDraggableType> = ({
field={field}
name={name}
value={value}
timelineId={timelineId}
tooltipContent={tooltipContent}
queryValue={queryValue}
>

View file

@ -12,15 +12,34 @@ import React from 'react';
import '../../mock/match_media';
import '../../mock/react_beautiful_dnd';
import { mockDetailItemData, mockDetailItemDataId, rawEventData, TestProviders } from '../../mock';
import {
mockDetailItemData,
mockDetailItemDataId,
mockEcsDataWithAlert,
rawEventData,
TestProviders,
} from '../../mock';
import { EventDetails, EventsViewType } from './event_details';
import { EventDetails, EVENT_DETAILS_CONTEXT_ID, EventsViewType } from './event_details';
import { mockBrowserFields } from '../../containers/source/mock';
import { mockAlertDetailsData } from './__mocks__';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TimelineTabs } from '../../../../common/types/timeline';
import { useInvestigationTimeEnrichment } from '../../containers/cti/event_enrichment';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
jest.mock('../../../timelines/components/timeline/body/renderers', () => {
return {
defaultRowRenderers: [
{
id: 'test',
isInstance: () => true,
renderRow: jest.fn(),
},
],
};
});
jest.mock('../../lib/kibana');
const originalKibanaLib = jest.requireActual('../../lib/kibana');
@ -47,6 +66,7 @@ describe('EventDetails', () => {
const defaultProps = {
browserFields: mockBrowserFields,
data: mockDetailItemData,
detailsEcsData: mockEcsDataWithAlert,
id: mockDetailItemDataId,
isAlert: false,
onEventViewSelected: jest.fn(),
@ -140,6 +160,18 @@ describe('EventDetails', () => {
it('render investigation guide', () => {
expect(alertsWrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(true);
});
test('it renders the alert / event via a renderer', () => {
expect(alertsWrapper.find('[data-test-subj="renderer"]').first().text()).toEqual(
'Access event with source 192.168.0.1:80, destination 192.168.0.3:6343, by john.dee on apache'
);
});
test('it invokes `renderRow()` with the expected `contextId`, to ensure unique drag & drop IDs', () => {
expect((defaultRowRenderers[0].renderRow as jest.Mock).mock.calls[0][0].contextId).toEqual(
EVENT_DETAILS_CONTEXT_ID
);
});
});
describe('threat intel tab', () => {

View file

@ -26,6 +26,7 @@ import { ThreatSummaryView } from './cti_details/threat_summary_view';
import { ThreatDetailsView } from './cti_details/threat_details_view';
import * as i18n from './translations';
import { AlertSummaryView } from './alert_summary_view';
import type { Ecs } from '../../../../common/ecs';
import type { BrowserFields } from '../../containers/source';
import { useInvestigationTimeEnrichment } from '../../containers/cti/event_enrichment';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
@ -37,11 +38,15 @@ import {
timelineDataToEnrichment,
} from './cti_details/helpers';
import { EnrichmentRangePicker } from './cti_details/enrichment_range_picker';
import { Reason } from './reason';
import { InvestigationGuideView } from './investigation_guide_view';
import { Overview } from './overview';
import { Insights } from './insights/insights';
import { useRiskScoreData } from './use_risk_score_data';
import { getRowRenderer } from '../../../timelines/components/timeline/body/renderers/get_row_renderer';
import { DETAILS_CLASS_NAME } from '../../../timelines/components/timeline/body/renderers/helpers';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
export const EVENT_DETAILS_CONTEXT_ID = 'event-details';
type EventViewTab = EuiTabbedContentTab;
@ -60,6 +65,7 @@ export enum EventsViewType {
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
detailsEcsData: Ecs | null;
id: string;
indexName: string;
isAlert: boolean;
@ -100,9 +106,18 @@ const TabContentWrapper = styled.div`
position: relative;
`;
const RendererContainer = styled.div`
overflow-x: auto;
padding-right: ${(props) => props.theme.eui.euiSizeXS};
& .${DETAILS_CLASS_NAME} .euiFlexGroup {
justify-content: flex-start;
}
`;
const EventDetailsComponent: React.FC<Props> = ({
browserFields,
data,
detailsEcsData,
id,
indexName,
isAlert,
@ -148,6 +163,14 @@ const EventDetailsComponent: React.FC<Props> = ({
const { hostRisk, userRisk, isLicenseValid } = useRiskScoreData(data);
const renderer = useMemo(
() =>
detailsEcsData != null
? getRowRenderer({ data: detailsEcsData, rowRenderers: defaultRowRenderers })
: null,
[detailsEcsData]
);
const summaryTab: EventViewTab | undefined = useMemo(
() =>
isAlert
@ -169,7 +192,20 @@ const EventDetailsComponent: React.FC<Props> = ({
isReadOnly={isReadOnly}
/>
<EuiSpacer size="l" />
<Reason eventId={id} data={data} />
{renderer != null && detailsEcsData != null && (
<div>
<RendererContainer data-test-subj="renderer">
{renderer.renderRow({
contextId: EVENT_DETAILS_CONTEXT_ID,
data: detailsEcsData,
isDraggable: isDraggable ?? false,
timelineId,
})}
</RendererContainer>
</div>
)}
<EuiHorizontalRule />
<AlertSummaryView
{...{
@ -220,22 +256,24 @@ const EventDetailsComponent: React.FC<Props> = ({
}
: undefined,
[
allEnrichments,
browserFields,
data,
detailsEcsData,
enrichmentCount,
goToTableTab,
handleOnEventClosed,
hostRisk,
id,
indexName,
isAlert,
data,
browserFields,
isDraggable,
timelineId,
enrichmentCount,
allEnrichments,
isEnrichmentsLoading,
hostRisk,
goToTableTab,
handleOnEventClosed,
isReadOnly,
userRisk,
isLicenseValid,
isReadOnly,
renderer,
timelineId,
userRisk,
]
);

View file

@ -1,41 +0,0 @@
/*
* 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 { EuiTextColor, EuiFlexItem } from '@elastic/eui';
import { ALERT_REASON } from '@kbn/rule-data-utils';
import React, { useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { EVENT_DETAILS_PLACEHOLDER } from '../../../timelines/components/side_panel/event_details/translations';
import { getFieldValue } from '../../../detections/components/host_isolation/helpers';
interface Props {
data: TimelineEventsDetailsItem[];
eventId: string;
}
export const ReasonComponent: React.FC<Props> = ({ eventId, data }) => {
const reason = useMemo(() => {
const siemSignalsReason = getFieldValue(
{ category: 'signal', field: 'signal.alert.reason' },
data
);
const aadReason = getFieldValue({ category: 'kibana', field: ALERT_REASON }, data);
return aadReason.length > 0 ? aadReason : siemSignalsReason;
}, [data]);
if (!eventId) {
return <EuiTextColor color="subdued">{EVENT_DETAILS_PLACEHOLDER}</EuiTextColor>;
}
return reason ? <EuiFlexItem grow={false}>{reason}</EuiFlexItem> : null;
};
ReasonComponent.displayName = 'ReasonComponent';
export const Reason = React.memo(ReasonComponent);

View file

@ -33,6 +33,7 @@ import { useKibana } from '../../lib/kibana';
import { GraphOverlay } from '../../../timelines/components/graph_overlay';
import type { FieldEditorActions } from '../../../timelines/components/fields_browser';
import { useFieldBrowserOptions } from '../../../timelines/components/fields_browser';
import { getRowRenderer } from '../../../timelines/components/timeline/body/renderers/get_row_renderer';
import {
useSessionViewNavigation,
useSessionView,
@ -230,6 +231,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
fieldBrowserOptions,
filters: globalFilters,
filterStatus: currentFilter,
getRowRenderer,
globalFullScreen,
graphEventId,
graphOverlay,

View file

@ -8,6 +8,7 @@ import { RowRendererId } from '../../../../../common/types/timeline';
import * as i18n from './translations';
export const eventRendererNames: { [key in RowRendererId]: string } = {
[RowRendererId.alert]: i18n.ALERT_NAME,
[RowRendererId.alerts]: i18n.ALERTS_NAME,
[RowRendererId.auditd]: i18n.AUDITD_NAME,
[RowRendererId.auditd_file]: i18n.AUDITD_FILE_NAME,

View file

@ -7,6 +7,10 @@
import { i18n } from '@kbn/i18n';
export const ALERT_NAME = i18n.translate('xpack.securitySolution.eventRenderers.alertName', {
defaultMessage: 'Alert',
});
export const ALERTS_NAME = i18n.translate('xpack.securitySolution.eventRenderers.alertsName', {
defaultMessage: 'Alerts',
});

View file

@ -18,6 +18,7 @@ import {
import React from 'react';
import styled from 'styled-components';
import type { Ecs } from '../../../../../common/ecs';
import type { TimelineTabs } from '../../../../../common/types/timeline';
import type { BrowserFields } from '../../../../common/containers/source';
import { EventDetails } from '../../../../common/components/event_details/event_details';
@ -29,6 +30,7 @@ export type HandleOnEventClosed = () => void;
interface Props {
browserFields: BrowserFields;
detailsData: TimelineEventsDetailsItem[] | null;
detailsEcsData: Ecs | null;
event: { eventId: string; indexName: string };
isAlert: boolean;
isDraggable?: boolean;
@ -105,6 +107,7 @@ export const ExpandableEvent = React.memo<Props>(
isDraggable,
loading,
detailsData,
detailsEcsData,
rawEventData,
handleOnEventClosed,
isReadOnly,
@ -123,6 +126,7 @@ export const ExpandableEvent = React.memo<Props>(
<EventDetails
browserFields={browserFields}
data={detailsData ?? []}
detailsEcsData={detailsEcsData}
id={event.eventId}
isAlert={isAlert}
indexName={event.indexName}

View file

@ -9,6 +9,7 @@ import { EuiFlyoutBody } from '@elastic/eui';
import styled from 'styled-components';
import React from 'react';
import { EndpointIsolateSuccess } from '../../../../../common/components/endpoint/host_isolation';
import type { Ecs } from '../../../../../../common/ecs';
import { HostIsolationPanel } from '../../../../../detections/components/host_isolation';
import type {
BrowserFields,
@ -35,6 +36,7 @@ interface FlyoutBodyComponentProps {
alertId: string;
browserFields: BrowserFields;
detailsData: TimelineEventsDetailsItem[] | null;
detailsEcsData: Ecs | null;
event: { eventId: string; indexName: string };
handleIsolationActionSuccess: () => void;
handleOnEventClosed: HandleOnEventClosed;
@ -55,6 +57,7 @@ const FlyoutBodyComponent = ({
alertId,
browserFields,
detailsData,
detailsEcsData,
event,
handleIsolationActionSuccess,
handleOnEventClosed,
@ -90,6 +93,7 @@ const FlyoutBodyComponent = ({
<ExpandableEvent
browserFields={browserFields}
detailsData={detailsData}
detailsEcsData={detailsEcsData}
event={event}
isAlert={isAlert}
isDraggable={isDraggable}

View file

@ -65,6 +65,7 @@ export const useToGetInternalFlyout = () => {
alertId={alertId}
browserFields={browserFields}
detailsData={detailsData}
detailsEcsData={ecsData}
event={{ eventId: localAlert._id, indexName: localAlert._index }}
hostName={hostName ?? ''}
handleIsolationActionSuccess={handleIsolationActionSuccess}
@ -86,6 +87,7 @@ export const useToGetInternalFlyout = () => {
alertId,
browserFields,
detailsData,
ecsData,
handleIsolationActionSuccess,
hostName,
isAlert,

View file

@ -116,6 +116,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
alertId={alertId}
browserFields={browserFields}
detailsData={detailsData}
detailsEcsData={ecsData}
event={expandedEvent}
hostName={hostName}
handleIsolationActionSuccess={handleIsolationActionSuccess}
@ -159,6 +160,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
<ExpandableEvent
browserFields={browserFields}
detailsData={detailsData}
detailsEcsData={ecsData}
event={expandedEvent}
isAlert={isAlert}
isDraggable={isDraggable}
@ -175,6 +177,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
alertId,
browserFields,
detailsData,
ecsData,
expandedEvent,
handleIsolationActionSuccess,
handleOnEventClosed,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
@ -160,7 +161,7 @@ const StatefulEventComponent: React.FC<Props> = ({
);
const hasRowRenderers: boolean = useMemo(
() => getRowRenderer(event.ecs, rowRenderers) != null,
() => getRowRenderer({ data: event.ecs, rowRenderers }) != null,
[event.ecs, rowRenderers]
);
@ -280,6 +281,7 @@ const StatefulEventComponent: React.FC<Props> = ({
<EventsTrSupplement
className="siemEventsTable__trSupplement--notes"
data-test-subj="event-notes-flex-item"
$display="block"
>
<NoteCards
ariaRowindex={ariaRowindex}
@ -291,16 +293,20 @@ const StatefulEventComponent: React.FC<Props> = ({
/>
</EventsTrSupplement>
<EventsTrSupplement>
<StatefulRowRenderer
ariaRowindex={ariaRowindex}
containerRef={containerRef}
event={event}
lastFocusedAriaColindex={lastFocusedAriaColindex}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</EventsTrSupplement>
<EuiFlexGroup gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
<EventsTrSupplement>
<StatefulRowRenderer
ariaRowindex={ariaRowindex}
containerRef={containerRef}
event={event}
lastFocusedAriaColindex={lastFocusedAriaColindex}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</EventsTrSupplement>
</EuiFlexItem>
</EuiFlexGroup>
</EventsTrSupplementContainerWrapper>
</EventsTrGroup>
</StatefulEventContext.Provider>

View file

@ -59,7 +59,7 @@ export const StatefulRowRenderer = ({
});
const rowRenderer = useMemo(
() => getRowRenderer(event.ecs, rowRenderers),
() => getRowRenderer({ data: event.ecs, rowRenderers }),
[event.ecs, rowRenderers]
);

View file

@ -0,0 +1,78 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../../../../../common/mock';
import { TimelineId } from '../../../../../../../../../common/types';
import { AlertFieldBadge } from '.';
const contextId = 'test';
const eventId = 'abcd';
const field = 'destination.ip';
const value = '127.0.0.1';
describe('AlertFieldBadge', () => {
test('it renders the expected value', () => {
render(
<TestProviders>
<AlertFieldBadge
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
showSeparator={false}
timelineId={TimelineId.detectionsPage}
value={value}
/>
</TestProviders>
);
expect(screen.getByTestId('alertFieldBadge')).toHaveTextContent('127.0.0.1');
});
test('it does NOT render a separator when `showSeparator` is false', () => {
const showSeparator = false;
render(
<TestProviders>
<AlertFieldBadge
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
showSeparator={showSeparator}
timelineId={TimelineId.detectionsPage}
value={value}
/>
</TestProviders>
);
expect(screen.queryByTestId('separator')).not.toBeInTheDocument();
});
test('it renders the expected value with a separator when `showSeparator` is true', () => {
const showSeparator = true;
render(
<TestProviders>
<AlertFieldBadge
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
showSeparator={showSeparator}
timelineId={TimelineId.detectionsPage}
value={value}
/>
</TestProviders>
);
expect(screen.getByTestId('alertFieldBadge')).toHaveTextContent('127.0.0.1,');
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 { EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { DraggableBadge } from '../../../../../../../../common/components/draggables';
import { AlertFieldFlexGroup } from '../../helpers';
export const DEFAULT_FIELD_TYPE = 'keyword';
interface Props {
contextId: string;
eventId: string;
field: string;
fieldType?: string;
isAggregatable?: boolean;
isDraggable: boolean;
showSeparator: boolean;
timelineId: string;
value: string | number | null | undefined;
}
const AlertFieldBadgeComponent: React.FC<Props> = ({
contextId,
eventId,
field,
fieldType = DEFAULT_FIELD_TYPE,
isAggregatable = true,
isDraggable,
showSeparator,
timelineId,
value,
}) => (
<AlertFieldFlexGroup
alignItems="center"
data-test-subj="alertFieldBadge"
$timelineId={timelineId}
gutterSize="none"
>
<EuiFlexItem grow={false}>
<DraggableBadge
contextId={`${contextId}-alert-field`}
eventId={eventId}
field={field}
fieldType={fieldType}
isAggregatable={isAggregatable}
isDraggable={isDraggable}
timelineId={timelineId}
value={value}
/>
</EuiFlexItem>
{showSeparator && (
<EuiFlexItem grow={false}>
<span data-test-subj="separator">{', '}</span>
</EuiFlexItem>
)}
</AlertFieldFlexGroup>
);
AlertFieldBadgeComponent.displayName = 'AlertFieldBadgeComponent';
export const AlertFieldBadge = React.memo(AlertFieldBadgeComponent);

View file

@ -0,0 +1,166 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../../../../common/mock';
import { AlertField } from '.';
const contextId = 'test';
const eventId = 'abcd';
const field = 'destination.ip';
const timelineId = 'test';
const prefix = 'this is the beginning';
const suffix = 'the end';
describe('AlertField', () => {
const singleValue = ['127.0.0.1'];
const multipleValues = ['127.0.0.1', '192.168.1.1', '10.0.0.1'];
test('it does NOT render the alert field when `values` is undefined', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
timelineId={timelineId}
values={undefined} // <-- undefined
/>
</TestProviders>
);
expect(screen.queryByTestId('alertField')).not.toBeInTheDocument();
});
test('it does NOT render a prefix by default', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
timelineId={timelineId}
values={multipleValues}
/>
</TestProviders>
);
expect(screen.queryByTestId('prefix')).not.toBeInTheDocument();
});
test('it renders the expected `prefix`', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
prefix={prefix}
timelineId={timelineId}
values={multipleValues}
/>
</TestProviders>
);
expect(screen.getByTestId('prefix')).toHaveTextContent('this is the beginning');
});
test('it renders the expected value when a single value is provided', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
timelineId={timelineId}
values={singleValue} // <-- a single value
/>
</TestProviders>
);
expect(screen.getByTestId('alertField')).toHaveTextContent('127.0.0.1');
});
test('it renders the expected comma-separated values when multiple values are provided', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
timelineId={timelineId}
values={multipleValues} // <-- multiple values
/>
</TestProviders>
);
expect(screen.getByTestId('alertField')).toHaveTextContent('127.0.0.1, 192.168.1.1, 10.0.0.1');
});
test('it does NOT render a suffix by default', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
timelineId={timelineId}
values={multipleValues}
/>
</TestProviders>
);
expect(screen.queryByTestId('suffix')).not.toBeInTheDocument();
});
test('it renders the expected `suffix`', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
suffix={suffix}
timelineId={timelineId}
values={multipleValues}
/>
</TestProviders>
);
expect(screen.getByTestId('suffix')).toHaveTextContent('the end');
});
test('it renders the expected prefix, comma-separated values, and finally suffix, when all of the above are provided', () => {
render(
<TestProviders>
<AlertField
contextId={contextId}
eventId={eventId}
field={field}
isDraggable={false}
prefix={prefix}
suffix={suffix}
timelineId={timelineId}
values={multipleValues} // <-- multiple values
/>
</TestProviders>
);
expect(screen.getByTestId('alertField')).toHaveTextContent(
'this is the beginning127.0.0.1, 192.168.1.1, 10.0.0.1the end'
);
});
});

View file

@ -0,0 +1,80 @@
/*
* 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 { EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { AlertFieldBadge } from './alert_field_badge';
import { AlertFieldFlexGroup } from '../helpers';
export const DEFAULT_FIELD_TYPE = 'keyword';
interface Props {
contextId: string;
'data-test-subj'?: string;
eventId: string;
field: string;
fieldType?: string;
isAggregatable?: boolean;
isDraggable: boolean;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
timelineId: string;
values: string[] | number[] | null | undefined;
}
const AlertFieldComponent: React.FC<Props> = ({
contextId,
'data-test-subj': dataTestSubj = 'alertField',
eventId,
field,
fieldType = DEFAULT_FIELD_TYPE,
isAggregatable = true,
isDraggable,
prefix,
suffix,
timelineId,
values,
}) =>
values != null ? (
<AlertFieldFlexGroup
alignItems="center"
data-test-subj={dataTestSubj}
$timelineId={timelineId}
gutterSize="none"
>
{prefix != null && (
<EuiFlexItem data-test-subj="prefix" grow={false}>
{prefix}
</EuiFlexItem>
)}
{values.map((x, i) => (
<EuiFlexItem key={`${x}-${i}`} grow={false}>
<AlertFieldBadge
contextId={`${contextId}-alert-field`}
eventId={eventId}
field={field}
fieldType={fieldType}
isAggregatable={isAggregatable}
isDraggable={isDraggable}
showSeparator={i < values.length - 1}
timelineId={timelineId}
value={x}
/>
</EuiFlexItem>
))}
{suffix != null && (
<EuiFlexItem data-test-subj="suffix" grow={false}>
{suffix}
</EuiFlexItem>
)}
</AlertFieldFlexGroup>
) : null;
AlertFieldComponent.displayName = 'AlertFieldComponent';
export const AlertField = React.memo(AlertFieldComponent);

View file

@ -0,0 +1,124 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import type { Ecs } from '../../../../../../../../common/ecs';
import { TestProviders } from '../../../../../../../common/mock';
import { TimelineId } from '../../../../../../../../common/types';
import { AlertFieldFlexGroup, DEFAULT_GAP, eventKindMatches, showWith } from '.';
describe('helpers', () => {
describe('eventKindMatches', () => {
test('it returns true when `eventKind` is an array of (just) `signal`', () => {
const eventKind = ['signal'];
expect(eventKindMatches(eventKind)).toBe(true);
});
test('it returns true when `eventKind` is an array of (just) mixed-case `sIgNaL`', () => {
const eventKind = ['sIgNaL'];
expect(eventKindMatches(eventKind)).toBe(true);
});
test('it returns true when `eventKind` is an array containing other values, AND a mixed-case `sIgNaL`', () => {
const eventKind = ['foo', 'bar', 'sIgNaL', '@baz'];
expect(eventKindMatches(eventKind)).toBe(true);
});
test('it returns false when `eventKind` does NOT contain the value `signal`', () => {
const eventKind = ['foo', 'bar', '@baz'];
expect(eventKindMatches(eventKind)).toBe(false);
});
test('it returns false when `eventKind` is `undefined`', () => {
const eventKind = undefined;
expect(eventKindMatches(eventKind)).toBe(false);
});
test('it returns false when `eventKind` is empty', () => {
const eventKind: string[] = [];
expect(eventKindMatches(eventKind)).toBe(false);
});
});
describe('showWith', () => {
const data: Ecs = {
_id: 'abcd',
destination: {
ip: ['10.0.0.1'],
},
source: {
ip: ['127.0.0.1'],
},
};
test('it returns true when `data` contains (just one) of the `fieldNames`', () => {
const fieldNames = ['source.ip'];
expect(showWith({ data, fieldNames })).toBe(true);
});
test('it returns true when `data` contains more than one of the `fieldNames`', () => {
const fieldNames = ['destination.ip', 'source.ip'];
expect(showWith({ data, fieldNames })).toBe(true);
});
test('it returns false when `data` does NOT contain any of the `fieldNames`', () => {
const fieldNames = ['destination.ip', 'source.ip'];
expect(showWith({ data: { _id: 'abcd' }, fieldNames })).toBe(false);
});
test('it returns false when `fieldNames` does NOT contain any of the fields in `data`', () => {
const fieldNames = ['foo', 'bar', '@baz'];
expect(showWith({ data, fieldNames })).toBe(false);
});
test('it returns false when `fieldNames` is empty', () => {
const fieldNames: string[] = [];
expect(showWith({ data, fieldNames })).toBe(false);
});
});
describe('AlertFieldFlexGroup', () => {
test('it has a 0px gap between flex items for the active timeline', () => {
render(
<TestProviders>
<AlertFieldFlexGroup
data-test-subj="test"
$timelineId={TimelineId.active} // <-- the active timeline
/>
</TestProviders>
);
expect(screen.getByTestId('test')).toHaveStyleRule('gap', '0px');
});
test('it has the default gap between flex items for any other (non-active) timeline', () => {
render(
<TestProviders>
<AlertFieldFlexGroup
data-test-subj="test"
$timelineId={TimelineId.detectionsPage} // <-- the alerts page
/>
</TestProviders>
);
expect(screen.getByTestId('test')).toHaveStyleRule('gap', `${DEFAULT_GAP}px`);
});
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { EuiFlexGroup } from '@elastic/eui';
import { has } from 'lodash/fp';
import styled from 'styled-components';
import type { Ecs } from '../../../../../../../../common/ecs';
import { TimelineId } from '../../../../../../../../common/types';
export const DESTINATION_IP = 'destination.ip';
export const DESTINATION_PORT = 'destination.port';
export const EVENT_CATEGORY = 'event.category';
export const FILE_NAME = 'file.name';
export const HOST_NAME = 'host.name';
export const ID = '_id';
export const KIBANA_ALERT_RULE_NAME = 'kibana.alert.rule.name';
export const KIBANA_ALERT_SEVERITY = 'kibana.alert.severity';
export const PROCESS_PARENT_NAME = 'process.parent.name';
export const PROCESS_NAME = 'process.name';
export const SOURCE_IP = 'source.ip';
export const SOURCE_PORT = 'source.port';
export const USER_NAME = 'user.name';
export const eventKindMatches = (eventKind: string[] | undefined): boolean =>
eventKind?.some((x) => x.toLocaleLowerCase != null && x.toLowerCase() === 'signal') ?? false;
export const showWith = ({ data, fieldNames }: { data: Ecs; fieldNames: string[] }): boolean =>
fieldNames.some((x) => has(x, data));
/** Show the word `with` if any of these fields are populated */
export const WITH_FIELD_NAMES = [
DESTINATION_IP,
DESTINATION_PORT,
FILE_NAME,
PROCESS_NAME,
PROCESS_PARENT_NAME,
SOURCE_IP,
SOURCE_PORT,
];
export const DEFAULT_GAP = 3; // px
export const AlertFieldFlexGroup = styled(EuiFlexGroup)<{ $timelineId: string }>`
flex-grow: 0;
gap: ${({ $timelineId }) => ($timelineId === TimelineId.active ? 0 : DEFAULT_GAP)}px;
`;

View file

@ -0,0 +1,245 @@
/*
* 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 { render, screen } from '@testing-library/react';
import { omit } from 'lodash/fp';
import React from 'react';
import type { Ecs } from '../../../../../../../common/ecs';
import { TestProviders } from '../../../../../../common/mock';
import {
DESTINATION_IP,
DESTINATION_PORT,
EVENT_CATEGORY,
FILE_NAME,
HOST_NAME,
KIBANA_ALERT_RULE_NAME,
KIBANA_ALERT_SEVERITY,
PROCESS_NAME,
PROCESS_PARENT_NAME,
SOURCE_IP,
SOURCE_PORT,
USER_NAME,
WITH_FIELD_NAMES,
} from './helpers';
import { alertRenderer } from '.';
const dataWithAllFields: Ecs = {
_id: 'abcd',
destination: {
ip: ['10.0.0.1'],
port: [5678],
},
event: {
category: ['network'],
kind: ['signal'], // <-- this makes it an alert
},
file: {
name: ['mimikatz.exe'],
},
host: {
name: ['gracious'],
},
kibana: {
alert: {
rule: {
name: ['panic'],
parameters: {},
uuid: [],
},
severity: ['low'],
},
},
process: {
name: ['zsh'],
parent: {
name: ['mom'],
},
},
source: {
ip: ['127.0.0.1'],
port: [1234],
},
timestamp: '2022-08-24T12:51:18.427Z',
user: {
name: ['root'],
},
};
describe('alertRenderer', () => {
describe('isInstance', () => {
test('it returns true when when `event.kind` is `signal`', () => {
const isAlert: Ecs = {
_id: 'abcd',
event: {
kind: ['signal'], // <-- this makes it an alert
},
};
expect(alertRenderer.isInstance(isAlert)).toBe(true);
});
test('it returns true when when `event.kind` has multiple values, and one of them is (mixed case) `sIgNaL`', () => {
const alsoAnAlert: Ecs = {
_id: 'abcd',
event: {
kind: ['process', 'sIgNaL'], // <-- also an alert
},
};
expect(alertRenderer.isInstance(alsoAnAlert)).toBe(true);
});
test('it returns false when when `event.kind` is NOT `signal`', () => {
const notAnAlert: Ecs = {
_id: 'abcd',
event: {
kind: ['foozle'], // <-- not an alert
},
};
expect(alertRenderer.isInstance(notAnAlert)).toBe(false);
});
test('it returns false when `event.kind` is NOT present', () => {
const noEventKind: Ecs = {
_id: 'abcd',
};
expect(alertRenderer.isInstance(noEventKind)).toBe(false);
});
});
describe('rendering alert fields', () => {
const fields = [
{
field: EVENT_CATEGORY,
expected: 'network',
},
{
field: PROCESS_NAME,
expected: 'process zsh,',
},
{
field: PROCESS_PARENT_NAME,
expected: 'parent process mom,',
},
{
field: FILE_NAME,
expected: 'file mimikatz.exe,',
},
{
field: SOURCE_IP,
expected: 'source 127.0.0.1',
},
{
field: SOURCE_PORT,
expected: ':1234,',
},
{
field: DESTINATION_IP,
expected: 'destination 10.0.0.1',
},
{
field: DESTINATION_PORT,
expected: ':5678,',
},
{
field: USER_NAME,
expected: 'by root',
},
{
field: HOST_NAME,
expected: 'on gracious',
},
{
field: KIBANA_ALERT_SEVERITY,
expected: 'created low alert',
},
{
field: KIBANA_ALERT_RULE_NAME,
expected: 'panic.',
},
];
fields.forEach(({ field, expected }) => {
test(`it renders the expected value (and prefix / suffix when applicable) for the ${field} field`, () => {
render(
<TestProviders>
{alertRenderer.renderRow({
data: dataWithAllFields,
isDraggable: false,
timelineId: 'test',
})}
</TestProviders>
);
expect(screen.getByTestId(field)).toHaveTextContent(expected);
});
});
});
test('it (always) renders the "event" static text', () => {
render(
<TestProviders>
{alertRenderer.renderRow({
data: dataWithAllFields,
isDraggable: false,
timelineId: 'test',
})}
</TestProviders>
);
expect(screen.getByTestId('event')).toHaveTextContent('event');
});
describe('with (conditionally rendered static text)', () => {
test('it renders the static text "with" when `showWith` returns true', () => {
render(
<TestProviders>
{alertRenderer.renderRow({
data: dataWithAllFields,
isDraggable: false,
timelineId: 'test',
})}
</TestProviders>
);
expect(screen.getByTestId('with')).toHaveTextContent('with');
});
test('it doses NOT render the static text "with" when `showWith` returns false', () => {
render(
<TestProviders>
{alertRenderer.renderRow({
data: omit(WITH_FIELD_NAMES, dataWithAllFields) as Ecs,
isDraggable: false,
timelineId: 'test',
})}
</TestProviders>
);
expect(screen.queryByTestId('with')).not.toBeInTheDocument();
});
});
test('it renders all the expected fields, values, and static text', () => {
render(
<TestProviders>
{alertRenderer.renderRow({
data: dataWithAllFields,
isDraggable: false,
timelineId: 'test',
})}
</TestProviders>
);
expect(screen.getByTestId('alertRenderer')).toHaveTextContent(
'network event with process zsh, parent process mom, file mimikatz.exe, source 127.0.0.1:1234, destination 10.0.0.1:5678, by root on gracious created low alert panic.'
);
});
});

View file

@ -0,0 +1,225 @@
/*
* 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 { get } from 'lodash/fp';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { AlertField } from './alert_field';
import type { RowRenderer } from '../../../../../../../common/types';
import { RowRendererId } from '../../../../../../../common/types';
import {
ID,
DESTINATION_IP,
DESTINATION_PORT,
EVENT_CATEGORY,
FILE_NAME,
HOST_NAME,
KIBANA_ALERT_RULE_NAME,
KIBANA_ALERT_SEVERITY,
PROCESS_NAME,
PROCESS_PARENT_NAME,
SOURCE_IP,
SOURCE_PORT,
USER_NAME,
WITH_FIELD_NAMES,
eventKindMatches,
showWith,
} from './helpers';
import { Details } from '../helpers';
import { RowRendererContainer } from '../row_renderer';
import * as i18n from './translations';
export const DEFAULT_CONTEXT_ID = 'alert-renderer';
const AlertRendererFlexGroup = styled(EuiFlexGroup)`
gap: ${({ theme }) => theme.eui.euiSizeXS};
`;
export const alertRenderer: RowRenderer = {
id: RowRendererId.alert,
isInstance: (ecs) => eventKindMatches(get('event.kind', ecs)),
renderRow: ({ contextId = DEFAULT_CONTEXT_ID, data, isDraggable, timelineId }) => {
const eventId = get(ID, data);
const destinationIp = get(DESTINATION_IP, data);
const destinationPort = get(DESTINATION_PORT, data);
const eventCategory = get(EVENT_CATEGORY, data);
const fileName = get(FILE_NAME, data);
const hostName = get(HOST_NAME, data);
const kibanaAlertRuleName = get(KIBANA_ALERT_RULE_NAME, data);
const kibanaAlertSeverity = get(KIBANA_ALERT_SEVERITY, data);
const processName = get(PROCESS_NAME, data);
const processParentName = get(PROCESS_PARENT_NAME, data);
const sourceIp = get(SOURCE_IP, data);
const sourcePort = get(SOURCE_PORT, data);
const userName = get(USER_NAME, data);
return (
<RowRendererContainer>
<Details data-test-subj="alertRenderer">
<AlertRendererFlexGroup
alignItems="center"
gutterSize="none"
justifyContent="center"
wrap={true}
>
<AlertField
contextId={contextId}
data-test-subj={EVENT_CATEGORY}
eventId={eventId}
field={EVENT_CATEGORY}
isDraggable={isDraggable}
timelineId={timelineId}
values={eventCategory}
/>
<EuiFlexItem grow={false}>
<span data-test-subj="event">{` ${i18n.EVENT} `}</span>
</EuiFlexItem>
{showWith({
data,
fieldNames: WITH_FIELD_NAMES,
}) && (
<EuiFlexItem grow={false}>
<span data-test-subj="with">{` ${i18n.WITH} `}</span>
</EuiFlexItem>
)}
<AlertField
contextId={contextId}
data-test-subj={PROCESS_NAME}
eventId={eventId}
field={PROCESS_NAME}
isDraggable={isDraggable}
prefix={` ${i18n.PROCESS} `}
suffix=", "
timelineId={timelineId}
values={processName}
/>
<AlertField
contextId={contextId}
data-test-subj={PROCESS_PARENT_NAME}
eventId={eventId}
field={PROCESS_PARENT_NAME}
isDraggable={isDraggable}
prefix={` ${i18n.PARENT_PROCESS} `}
suffix=", "
timelineId={timelineId}
values={processParentName}
/>
<AlertField
contextId={contextId}
data-test-subj={FILE_NAME}
eventId={eventId}
field={FILE_NAME}
isDraggable={isDraggable}
prefix={` ${i18n.FILE} `}
suffix=", "
timelineId={timelineId}
values={fileName}
/>
<AlertField
contextId={contextId}
data-test-subj={SOURCE_IP}
eventId={eventId}
field={SOURCE_IP}
isDraggable={isDraggable}
prefix={` ${i18n.SOURCE} `}
timelineId={timelineId}
values={sourceIp}
/>
<AlertField
contextId={contextId}
data-test-subj={SOURCE_PORT}
eventId={eventId}
field={SOURCE_PORT}
isDraggable={isDraggable}
prefix=":"
suffix=", "
timelineId={timelineId}
values={sourcePort}
/>
<AlertField
contextId={contextId}
data-test-subj={DESTINATION_IP}
eventId={eventId}
field={DESTINATION_IP}
isDraggable={isDraggable}
prefix={` ${i18n.DESTINATION} `}
timelineId={timelineId}
values={destinationIp}
/>
<AlertField
contextId={contextId}
data-test-subj={DESTINATION_PORT}
eventId={eventId}
field={DESTINATION_PORT}
isDraggable={isDraggable}
prefix=":"
suffix=", "
timelineId={timelineId}
values={destinationPort}
/>
<AlertField
contextId={contextId}
data-test-subj={USER_NAME}
eventId={eventId}
field={USER_NAME}
isDraggable={isDraggable}
prefix={` ${i18n.BY} `}
timelineId={timelineId}
values={userName}
/>
<AlertField
contextId={contextId}
data-test-subj={HOST_NAME}
eventId={eventId}
field={HOST_NAME}
isDraggable={isDraggable}
prefix={` ${i18n.ON} `}
timelineId={timelineId}
values={hostName}
/>
<AlertField
contextId={contextId}
data-test-subj={KIBANA_ALERT_SEVERITY}
eventId={eventId}
field={KIBANA_ALERT_SEVERITY}
isDraggable={isDraggable}
prefix={` ${i18n.CREATED} `}
suffix={` ${i18n.ALERT} `}
timelineId={timelineId}
values={kibanaAlertSeverity}
/>
<AlertField
contextId={contextId}
data-test-subj={KIBANA_ALERT_RULE_NAME}
eventId={eventId}
field={KIBANA_ALERT_RULE_NAME}
isDraggable={isDraggable}
suffix="."
timelineId={timelineId}
values={kibanaAlertRuleName}
/>
</AlertRendererFlexGroup>
</Details>
</RowRendererContainer>
);
},
};

View file

@ -0,0 +1,64 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ALERT = i18n.translate('xpack.securitySolution.renderers.alertRenderer.alertLabel', {
defaultMessage: 'alert',
});
export const BY = i18n.translate('xpack.securitySolution.renderers.alertRenderer.byLabel', {
defaultMessage: 'by',
});
export const CREATED = i18n.translate(
'xpack.securitySolution.renderers.alertRenderer.createdLabel',
{
defaultMessage: 'created',
}
);
export const DESTINATION = i18n.translate(
'xpack.securitySolution.renderers.alertRenderer.destinationLabel',
{
defaultMessage: 'destination',
}
);
export const EVENT = i18n.translate('xpack.securitySolution.renderers.alertRenderer.eventLabel', {
defaultMessage: 'event',
});
export const FILE = i18n.translate('xpack.securitySolution.renderers.alertRenderer.fileLabel', {
defaultMessage: 'file',
});
export const ON = i18n.translate('xpack.securitySolution.renderers.alertRenderer.onLabel', {
defaultMessage: 'on',
});
export const PARENT_PROCESS = i18n.translate(
'xpack.securitySolution.renderers.alertRenderer.parentProcessLabel',
{
defaultMessage: 'parent process',
}
);
export const PROCESS = i18n.translate(
'xpack.securitySolution.renderers.alertRenderer.processLabel',
{
defaultMessage: 'process',
}
);
export const SOURCE = i18n.translate('xpack.securitySolution.renderers.alertRenderer.sourceLabel', {
defaultMessage: 'source',
});
export const WITH = i18n.translate('xpack.securitySolution.renderers.alertRenderer.withLabel', {
defaultMessage: 'with',
});

View file

@ -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 type { Ecs } from '../../../../../../../common/ecs';
import { RowRendererId } from '../../../../../../../common/types';
import { combineRenderers } from '.';
describe('combineRenderers', () => {
const contextId = 'abcd';
const a = {
id: RowRendererId.netflow,
isInstance: jest.fn(),
renderRow: jest.fn(),
};
const b = {
id: RowRendererId.registry,
isInstance: jest.fn(),
renderRow: jest.fn(),
};
const data: Ecs = {
_id: 'abcd',
};
beforeEach(() => jest.clearAllMocks());
it('returns a renderer with the expected id', () => {
const id = RowRendererId.library; // typically id from 'a', or 'b', but it can be any value
expect(combineRenderers({ a, b, id }).id).toEqual(id);
});
describe('isInstance', () => {
it('returns true when `a` is an instance and `b` is an instance', () => {
a.isInstance.mockReturnValue(true);
b.isInstance.mockReturnValue(true);
expect(combineRenderers({ a, b, id: a.id }).isInstance(data)).toBe(true);
});
it('returns true when `a` is an instance and `b` is NOT an instance', () => {
a.isInstance.mockReturnValue(true);
b.isInstance.mockReturnValue(false);
expect(combineRenderers({ a, b, id: a.id }).isInstance(data)).toBe(true);
});
it('returns true when `a` is NOT an instance and `b` is an instance', () => {
a.isInstance.mockReturnValue(false);
b.isInstance.mockReturnValue(true);
expect(combineRenderers({ a, b, id: a.id }).isInstance(data)).toBe(true);
});
it('returns false when `a` is NOT an instance and `b` is NOT an instance', () => {
a.isInstance.mockReturnValue(false);
b.isInstance.mockReturnValue(false);
expect(combineRenderers({ a, b, id: a.id }).isInstance(data)).toBe(false);
});
});
describe('renderRow', () => {
const isDraggable = false;
const timelineId = 'test';
it('renders `a` and `b` when `a` is an instance and `b` is an instance', () => {
a.isInstance.mockReturnValue(true);
b.isInstance.mockReturnValue(true);
combineRenderers({ a, b, id: a.id }).renderRow({
contextId,
data,
isDraggable: false,
timelineId,
});
expect(a.renderRow).toBeCalledWith({
contextId,
data,
isDraggable,
timelineId,
});
expect(b.renderRow).toBeCalledWith({
contextId,
data,
isDraggable,
timelineId,
});
});
it('renders (only) `a` when `a` is an instance and `b` is NOT an instance', () => {
a.isInstance.mockReturnValue(true);
b.isInstance.mockReturnValue(false);
combineRenderers({ a, b, id: a.id }).renderRow({
contextId,
data,
isDraggable,
timelineId,
});
expect(a.renderRow).toBeCalledWith({
contextId,
data,
isDraggable,
timelineId,
});
expect(b.renderRow).not.toBeCalled();
});
it('renders (only) `b` when `a` is NOT an instance and `b` is an instance', () => {
a.isInstance.mockReturnValue(false);
b.isInstance.mockReturnValue(true);
combineRenderers({ a, b, id: a.id }).renderRow({
contextId,
data,
isDraggable,
timelineId,
});
expect(a.renderRow).not.toBeCalled();
expect(b.renderRow).toBeCalledWith({
contextId,
data,
isDraggable,
timelineId,
});
});
it('renders NEITHER `a`, nor `b` when `a` is NOT an instance and `b` is NOT an instance', () => {
a.isInstance.mockReturnValue(false);
b.isInstance.mockReturnValue(false);
expect(a.renderRow).not.toBeCalled();
expect(b.renderRow).not.toBeCalled();
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 type { Ecs } from '../../../../../../../common/ecs';
import type { RowRenderer, RowRendererId } from '../../../../../../../common/types';
export const combineRenderers = ({
a,
b,
id,
}: {
a: RowRenderer;
b: RowRenderer;
id: RowRendererId;
}): RowRenderer => ({
id,
isInstance: (data: Ecs) => a.isInstance(data) || b.isInstance(data),
renderRow: ({
contextId,
data,
isDraggable,
timelineId,
}: {
contextId?: string;
data: Ecs;
isDraggable: boolean;
timelineId: string;
}) => (
<>
{a.isInstance(data) && a.renderRow({ contextId, data, isDraggable, timelineId })}
{b.isInstance(data) && b.renderRow({ contextId, data, isDraggable, timelineId })}
</>
),
});

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import { render, screen } from '@testing-library/react';
import { shallow } from 'enzyme';
import { cloneDeep } from 'lodash';
import React from 'react';
import { removeExternalLinkText } from '@kbn/securitysolution-io-ts-utils';
import { alertRenderer } from './alert_renderer';
import '../../../../../common/mock/match_media';
import type { Ecs } from '../../../../../../common/ecs';
import { mockTimelineData } from '../../../../../common/mock';
@ -54,7 +56,7 @@ describe('get_column_renderer', () => {
});
test('renders correctly against snapshot', () => {
const rowRenderer = getRowRenderer(nonSuricata, defaultRowRenderers);
const rowRenderer = getRowRenderer({ data: nonSuricata, rowRenderers: defaultRowRenderers });
const row = rowRenderer?.renderRow({
data: nonSuricata,
isDraggable: true,
@ -66,7 +68,7 @@ describe('get_column_renderer', () => {
});
test('should render plain row data when it is a non suricata row', () => {
const rowRenderer = getRowRenderer(nonSuricata, defaultRowRenderers);
const rowRenderer = getRowRenderer({ data: nonSuricata, rowRenderers: defaultRowRenderers });
const row = rowRenderer?.renderRow({
data: nonSuricata,
isDraggable: true,
@ -81,7 +83,7 @@ describe('get_column_renderer', () => {
});
test('should render a suricata row data when it is a suricata row', () => {
const rowRenderer = getRowRenderer(suricata, defaultRowRenderers);
const rowRenderer = getRowRenderer({ data: suricata, rowRenderers: defaultRowRenderers });
const row = rowRenderer?.renderRow({
data: suricata,
isDraggable: true,
@ -99,7 +101,7 @@ describe('get_column_renderer', () => {
test('should render a suricata row data if event.category is network_traffic', () => {
suricata.event = { ...suricata.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(suricata, defaultRowRenderers);
const rowRenderer = getRowRenderer({ data: suricata, rowRenderers: defaultRowRenderers });
const row = rowRenderer?.renderRow({
data: suricata,
isDraggable: true,
@ -117,7 +119,7 @@ describe('get_column_renderer', () => {
test('should render a zeek row data if event.category is network_traffic', () => {
zeek.event = { ...zeek.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(zeek, defaultRowRenderers);
const rowRenderer = getRowRenderer({ data: zeek, rowRenderers: defaultRowRenderers });
const row = rowRenderer?.renderRow({
data: zeek,
isDraggable: true,
@ -135,7 +137,7 @@ describe('get_column_renderer', () => {
test('should render a system row data if event.category is network_traffic', () => {
system.event = { ...system.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(system, defaultRowRenderers);
const rowRenderer = getRowRenderer({ data: system, rowRenderers: defaultRowRenderers });
const row = rowRenderer?.renderRow({
data: system,
isDraggable: true,
@ -153,7 +155,7 @@ describe('get_column_renderer', () => {
test('should render a auditd row data if event.category is network_traffic', () => {
auditd.event = { ...auditd.event, ...{ category: ['network_traffic'] } };
const rowRenderer = getRowRenderer(auditd, defaultRowRenderers);
const rowRenderer = getRowRenderer({ data: auditd, rowRenderers: defaultRowRenderers });
const row = rowRenderer?.renderRow({
data: auditd,
isDraggable: true,
@ -169,3 +171,121 @@ describe('get_column_renderer', () => {
);
});
});
describe('getRowRenderer', () => {
const auditd = cloneDeep(mockTimelineData[19].ecs);
describe('when the data is NOT an alert', () => {
test('it finds the the first matching instance of a row renderer', () => {
const renderer = getRowRenderer({ data: auditd, rowRenderers: defaultRowRenderers });
expect(renderer?.id).toEqual('auditd');
});
test('it returns null when there are no matching renderers', () => {
const renderer = getRowRenderer({
data: { _id: 'no-renderer-for-this-non-alert-data' },
rowRenderers: defaultRowRenderers,
});
expect(renderer).toBeNull();
});
describe('which renderers are shown', () => {
beforeEach(() => {
const renderer = getRowRenderer({ data: auditd, rowRenderers: defaultRowRenderers });
render(
<TestProviders>
<>{renderer?.renderRow({ data: auditd, isDraggable: false, timelineId: 'test' })}</>
</TestProviders>
);
});
test('it does NOT show the alert renderer, because the data is NOT an alert', () => {
expect(screen.queryByTestId('alertRenderer')).not.toBeInTheDocument();
});
test('it shows the event (non-alert) renderer', () => {
expect(screen.getByTestId('render-content-user.name')).toBeInTheDocument();
});
});
});
describe('when the data IS an alert and ALSO has an event renderer', () => {
const data = {
...auditd, // <-- a renderer exists for this data
event: {
...auditd.event,
kind: ['signal'], // <-- this data is (also) an alert
},
};
test('it (still) returns the expected event renderer id, even though the data is also an alert', () => {
const renderer = getRowRenderer({ data, rowRenderers: defaultRowRenderers });
expect(renderer?.id).toEqual('auditd');
});
test('it does NOT return a renderer with the `alertRenderer` id', () => {
const renderer = getRowRenderer({ data, rowRenderers: defaultRowRenderers });
expect(renderer?.id).not.toEqual(alertRenderer.id);
});
describe('which renderers are shown', () => {
beforeEach(() => {
const renderer = getRowRenderer({ data, rowRenderers: defaultRowRenderers });
render(
<TestProviders>
<>{renderer?.renderRow({ data, isDraggable: false, timelineId: 'test' })}</>
</TestProviders>
);
});
test('it shows the alert renderer, because the data is ALSO an alert', () => {
expect(screen.getByTestId('alertRenderer')).toBeInTheDocument();
});
test('it (also) shows the event renderer, because it is combined with the alert renderer', () => {
expect(screen.queryAllByTestId('render-content-user.name')[0]).toBeInTheDocument();
});
});
});
describe('when the data IS an alert, but does NOT match any non-alert renderers', () => {
const data = {
_id: 'event-does-NOT-match-any-non-alert-renderers',
event: {
kind: ['signal'], // <-- this is an alert
},
};
test('it falls back to returning the `alertRenderer`, because no other renderers match', () => {
const renderer = getRowRenderer({ data, rowRenderers: defaultRowRenderers });
expect(renderer?.id).toEqual(alertRenderer.id);
});
describe('which renderers are shown', () => {
beforeEach(() => {
const renderer = getRowRenderer({ data, rowRenderers: defaultRowRenderers });
render(
<TestProviders>
<>{renderer?.renderRow({ data, isDraggable: false, timelineId: 'test' })}</>
</TestProviders>
);
});
test('it (only) shows the alert renderer, because it did NOT match any non-alert renderers', () => {
expect(screen.getByTestId('alertRenderer')).toBeInTheDocument();
});
test('it does NOT show the event renderer, because none were found', () => {
expect(screen.queryByTestId('render-content-user.name')).not.toBeInTheDocument();
});
});
});
});

View file

@ -5,8 +5,34 @@
* 2.0.
*/
import { alertRenderer } from './alert_renderer';
import { combineRenderers } from './combine_renderers';
import type { RowRenderer } from '../../../../../../common/types';
import type { Ecs } from '../../../../../../common/ecs';
export const getRowRenderer = (ecs: Ecs, rowRenderers: RowRenderer[]): RowRenderer | null =>
rowRenderers.find((rowRenderer) => rowRenderer.isInstance(ecs)) ?? null;
/**
* This function may be used by both Timeline and the `Event Rendered view` in
* the Alerts table to return the first instance of a `RowRenderer` that
* matches the data
*/
export const getRowRenderer = ({
data,
rowRenderers,
}: {
data: Ecs;
rowRenderers: RowRenderer[];
}): RowRenderer | null => {
const renderer = rowRenderers.find((rowRenderer) => rowRenderer.isInstance(data)) ?? null;
if (alertRenderer.isInstance(data)) {
if (renderer != null) {
// The combined renderer will display details about the alert, combined
// with the content from the event renderer that was found:
return combineRenderers({ a: alertRenderer, b: renderer, id: renderer.id });
} else {
return alertRenderer;
}
}
return renderer;
};

View file

@ -27,11 +27,18 @@ export const getValues = (field: string, data: TimelineNonEcsData[]): string[] |
return undefined;
};
export const Details = styled.div`
export const DETAILS_CLASS_NAME = 'details';
export const Details = styled.div.attrs(() => ({
className: DETAILS_CLASS_NAME,
}))`
margin: 5px 0 5px 10px;
& .euiBadge {
margin: 2px 0 2px 0;
}
& .euiFlexGroup {
justify-content: center;
}
`;
Details.displayName = 'Details';

View file

@ -3,6 +3,7 @@
exports[`netflowRowRenderer renders correctly against snapshot 1`] = `
<DocumentFragment>
.c0 {
display: inline-block;
font-size: 12px;
line-height: 1.5;
padding-left: 12px;

View file

@ -5,19 +5,17 @@
* 2.0.
*/
import { EuiSpacer, EuiPanel } from '@elastic/eui';
import { EuiPanel, EuiText } from '@elastic/eui';
import { isEqual } from 'lodash/fp';
import React, { useMemo } from 'react';
import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types';
import { TimelineId } from '../../../../../../common/types';
import type { Ecs } from '../../../../../../common/ecs';
import { eventRendererNames } from '../../../row_renderers_browser/catalog/constants';
import type { 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),
@ -79,7 +77,10 @@ const ReasonCell: React.FC<{
ecsData: Ecs;
rowRenderers: RowRenderer[];
}> = ({ ecsData, rowRenderers, timelineId, value }) => {
const rowRenderer = useMemo(() => getRowRenderer(ecsData, rowRenderers), [ecsData, rowRenderers]);
const rowRenderer = useMemo(
() => getRowRenderer({ data: ecsData, rowRenderers }),
[ecsData, rowRenderers]
);
const rowRender = useMemo(() => {
return (
@ -98,14 +99,11 @@ const ReasonCell: React.FC<{
return (
<>
{rowRenderer && rowRender && !isPlainText ? (
<>
{value}
<h4>{i18n.REASON_RENDERER_TITLE(eventRendererNames[rowRenderer.id] ?? '')}</h4>
<EuiSpacer size="xs" />
<EuiPanel color="subdued" className="eui-xScroll" data-test-subj="reason-cell-renderer">
<EuiPanel color="subdued" className="eui-xScroll" data-test-subj="reason-cell-renderer">
<EuiText size="xs">
<div className="eui-displayInlineBlock">{rowRender}</div>
</EuiPanel>
</>
</EuiText>
</EuiPanel>
) : (
value
)}

View file

@ -292,7 +292,8 @@ export const EventsTrSupplementContainer = styled.div.attrs<WidthProp>(({ width
export const EventsTrSupplement = styled.div.attrs(({ className = '' }) => ({
className: `siemEventsTable__trSupplement ${className}` as string,
}))<{ className: string }>`
}))<{ className: string; $display?: 'block' | 'inline-block' }>`
display: ${({ $display }) => $display ?? 'inline-block'};
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
line-height: ${({ theme }) => theme.eui.euiLineHeight};
padding-left: ${({ theme }) => theme.eui.euiSizeM};

View file

@ -11,14 +11,14 @@ const mergedDoc = {
_index: 'index-1',
_id: 'id-1',
fields: {
'destination.address': ['9.99.99.9'],
'destination.ip': ['9.99.99.9'],
'destination.port': ['6789'],
'event.category': ['test'],
'file.name': ['sample'],
'host.name': ['host'],
'process.name': ['doingThings.exe'],
'process.parent.name': ['didThings.exe'],
'source.address': ['1.11.11.1'],
'source.ip': ['1.11.11.1'],
'source.port': ['1234'],
'user.name': ['test-user'],
'@timestamp': '2021-08-11T02:28:59.101Z',
@ -121,7 +121,7 @@ describe('reason_formatter', () => {
...mergedDoc,
fields: {
...mergedDoc.fields,
'destination.address': ['-'],
'destination.ip': ['-'],
'destination.port': ['-'],
},
};
@ -152,7 +152,7 @@ describe('reason_formatter', () => {
...mergedDoc,
fields: {
...mergedDoc.fields,
'source.address': ['-'],
'source.ip': ['-'],
'source.port': ['-'],
},
};

View file

@ -37,14 +37,14 @@ const getFieldsFromDoc = (mergedDoc: SignalSourceHit) => {
const reasonFields: ReasonFields = {};
const docToUse = mergedDoc?.fields || mergedDoc?._source || mergedDoc;
reasonFields.destinationAddress = getOr(null, 'destination.address', docToUse);
reasonFields.destinationAddress = getOr(null, 'destination.ip', docToUse);
reasonFields.destinationPort = getOr(null, 'destination.port', docToUse);
reasonFields.eventCategory = getOr(null, 'event.category', docToUse);
reasonFields.fileName = getOr(null, 'file.name', docToUse);
reasonFields.hostName = getOr(null, 'host.name', docToUse);
reasonFields.processName = getOr(null, 'process.name', docToUse);
reasonFields.processParentName = getOr(null, 'process.parent.name', docToUse);
reasonFields.sourceAddress = getOr(null, 'source.address', docToUse);
reasonFields.sourceAddress = getOr(null, 'source.ip', docToUse);
reasonFields.sourcePort = getOr(null, 'source.port', docToUse);
reasonFields.userName = getOr(null, 'user.name', docToUse);

View file

@ -210,6 +210,9 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf<
>;
export enum RowRendererId {
/** event.kind: signal */
alert = 'alert',
/** endpoint alerts (created on the endpoint) */
alerts = 'alerts',
auditd = 'auditd',
auditd_file = 'auditd_file',

View file

@ -12,10 +12,12 @@ export interface RowRenderer {
id: RowRendererId;
isInstance: (data: Ecs) => boolean;
renderRow: ({
contextId,
data,
isDraggable,
timelineId,
}: {
contextId?: string;
data: Ecs;
isDraggable: boolean;
timelineId: string;

View file

@ -68,6 +68,7 @@ import {
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import type { OnRowSelected, OnSelectAll } from '../types';
import type { Refetch } from '../../../store/t_grid/inputs';
import { Ecs } from '../../../../common/ecs';
import { getPageRowIndex } from '../../../../common/utils/pagination';
import { StatefulEventContext } from '../../stateful_event_context';
import { tGridActions, TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid';
@ -96,6 +97,13 @@ interface OwnProps {
filters?: Filter[];
filterQuery?: string;
filterStatus?: AlertStatus;
getRowRenderer?: ({
data,
rowRenderers,
}: {
data: Ecs;
rowRenderers: RowRenderer[];
}) => RowRenderer | null;
id: string;
indexNames: string[];
isEventViewer?: boolean;
@ -311,6 +319,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
filterQuery,
filters,
filterStatus,
getRowRenderer,
hasAlertsCrud,
hasAlertsCrudPermissions,
id,
@ -902,6 +911,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
appId={appId}
alertToolbar={alertToolbar}
events={data}
getRowRenderer={getRowRenderer}
leadingControlColumns={leadingTGridControlColumns ?? []}
onChangePage={onChangePage}
onChangeItemsPerPage={onChangeItemsPerPage}
@ -909,6 +919,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
pageSize={pageSize}
pageSizeOptions={itemsPerPageOptions}
rowRenderers={rowRenderers}
timelineId={id}
totalItemCount={totalItems}
/>
)}

View file

@ -5,12 +5,15 @@
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { eventRenderedProps, TestProviders } from '../../../mock';
import { EventRenderedView } from '.';
import { RowRendererId } from '../../../../common/types';
describe('event_rendered_view', () => {
beforeEach(() => jest.clearAllMocks());
test('it renders the timestamp correctly', () => {
render(
<TestProviders>
@ -21,4 +24,130 @@ describe('event_rendered_view', () => {
'2018-11-05T14:03:25-05:00'
);
});
describe('getRowRenderer', () => {
const props = {
...eventRenderedProps,
rowRenderers: [
{
id: RowRendererId.auditd_file,
isInstance: jest.fn().mockReturnValue(false),
renderRow: jest.fn(),
},
{
id: RowRendererId.netflow,
isInstance: jest.fn().mockReturnValue(true), // matches any data
renderRow: jest.fn(),
},
{
id: RowRendererId.registry,
isInstance: jest.fn().mockReturnValue(true), // also matches any data
renderRow: jest.fn(),
},
],
};
test(`it (only) renders the first matching renderer when 'getRowRenderer' is NOT provided as a prop`, () => {
render(
<TestProviders>
<EventRenderedView {...props} />
</TestProviders>
);
expect(props.rowRenderers[0].renderRow).not.toBeCalled(); // did not match
expect(props.rowRenderers[1].renderRow).toBeCalled(); // the first matching renderer
expect(props.rowRenderers[2].renderRow).not.toBeCalled(); // also matches, but should not be rendered
});
test(`it (only) renders the renderer returned by 'getRowRenderer' when it's provided as a prop`, () => {
const withGetRowRenderer = {
...props,
getRowRenderer: jest.fn().mockImplementation(() => props.rowRenderers[2]), // only match the last renderer
};
render(
<TestProviders>
<EventRenderedView {...withGetRowRenderer} />
</TestProviders>
);
expect(props.rowRenderers[0].renderRow).not.toBeCalled();
expect(props.rowRenderers[1].renderRow).not.toBeCalled();
expect(props.rowRenderers[2].renderRow).toBeCalled();
});
test(`it does NOT render the plain text version of the reason when a renderer is found`, () => {
render(
<TestProviders>
<EventRenderedView {...props} />
</TestProviders>
);
expect(screen.queryByTestId('plain-text-reason')).not.toBeInTheDocument();
});
test(`it renders the plain text reason when no row renderer was found, but the data contains an 'ecs.signal.reason'`, () => {
const reason = 'why not?';
const noRendererFound = {
...props,
events: [
...props.events,
{
_id: 'abcd',
data: [{ field: '@timestamp', value: ['2018-11-05T19:03:25.937Z'] }],
ecs: {
_id: 'abcd',
timestamp: '2018-11-05T19:03:25.937Z',
signal: {
reason,
},
},
},
],
getRowRenderer: jest.fn().mockImplementation(() => null), // no renderer was found
};
render(
<TestProviders>
<EventRenderedView {...noRendererFound} />
</TestProviders>
);
expect(screen.getAllByTestId('plain-text-reason')[0]).toHaveTextContent('why not?');
});
test(`it renders the plain text reason when no row renderer was found, but the data contains an 'ecs.kibana.alert.reason'`, () => {
const reason = 'do you really need a reason?';
const noRendererFound = {
...props,
events: [
...props.events,
{
_id: 'abcd',
data: [],
ecs: {
_id: 'abcd',
timestamp: '2018-11-05T19:03:25.937Z',
kibana: {
alert: {
reason,
},
},
},
},
],
getRowRenderer: jest.fn().mockImplementation(() => null), // no renderer was found
};
render(
<TestProviders>
<EventRenderedView {...noRendererFound} />
</TestProviders>
);
expect(screen.getAllByTestId('plain-text-reason')[0]).toHaveTextContent(
'do you really need a reason?'
);
});
});
});

View file

@ -12,7 +12,6 @@ import {
EuiDataGridControlColumn,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALERT_REASON, ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
@ -23,6 +22,7 @@ import styled from 'styled-components';
import { useUiSetting } from '@kbn/kibana-react-plugin/public';
import { Ecs } from '../../../../common/ecs';
import type { TimelineItem } from '../../../../common/search_strategy';
import type { RowRenderer } from '../../../../common/types';
import { RuleName } from '../../rule_name';
@ -57,12 +57,27 @@ const StyledEuiBasicTable = styled(EuiBasicTable as BasicTableType)`
& > div:last-child {
height: 72px;
}
& tr:nth-child(even) {
background-color: ${({ theme }) => theme.eui.euiColorLightestShade};
}
& tr:nth-child(odd) {
background-color: ${({ theme }) => theme.eui.euiColorEmptyShade};
}
`;
export interface EventRenderedViewProps {
alertToolbar: React.ReactNode;
appId: string;
events: TimelineItem[];
getRowRenderer?: ({
data,
rowRenderers,
}: {
data: Ecs;
rowRenderers: RowRenderer[];
}) => RowRenderer | null;
leadingControlColumns: EuiDataGridControlColumn[];
onChangePage: (newActivePage: number) => void;
onChangeItemsPerPage: (newItemsPerPage: number) => void;
@ -70,6 +85,7 @@ export interface EventRenderedViewProps {
pageSize: number;
pageSizeOptions: number[];
rowRenderers: RowRenderer[];
timelineId: string;
totalItemCount: number;
}
const PreferenceFormattedDateComponent = ({ value }: { value: Date }) => {
@ -85,6 +101,7 @@ const EventRenderedViewComponent = ({
alertToolbar,
appId,
events,
getRowRenderer,
leadingControlColumns,
onChangePage,
onChangeItemsPerPage,
@ -92,6 +109,7 @@ const EventRenderedViewComponent = ({
pageSize,
pageSizeOptions,
rowRenderers,
timelineId,
totalItemCount,
}: EventRenderedViewProps) => {
const ActionTitle = useMemo(
@ -178,34 +196,35 @@ const EventRenderedViewComponent = ({
render: (name: unknown, item: TimelineItem) => {
const ecsData = get(item, 'ecs');
const reason = get(item, `ecs.signal.reason`) ?? get(item, `ecs.${ALERT_REASON}`);
const rowRenderersValid = rowRenderers.filter((rowRenderer) =>
rowRenderer.isInstance(ecsData)
);
const rowRenderer =
getRowRenderer != null
? getRowRenderer({ data: ecsData, rowRenderers })
: rowRenderers.find((x) => x.isInstance(ecsData)) ?? null;
return (
<EuiFlexGroup gutterSize="none" direction="column" className="eui-fullWidth">
{reason && <EuiFlexItem>{reason}</EuiFlexItem>}
{rowRenderersValid.length > 0 &&
rowRenderersValid.map((rowRenderer) => (
<>
<EuiHorizontalRule size="half" margin="xs" />
<EventRenderedFlexItem className="eui-xScroll">
<div className="eui-displayInlineBlock">
{rowRenderer.renderRow({
data: ecsData,
isDraggable: false,
timelineId: 'NONE',
})}
</div>
</EventRenderedFlexItem>
</>
))}
{rowRenderer != null ? (
<EventRenderedFlexItem className="eui-xScroll">
<div className="eui-displayInlineBlock">
{rowRenderer.renderRow({
data: ecsData,
isDraggable: false,
timelineId,
})}
</div>
</EventRenderedFlexItem>
) : (
<>
{reason && <EuiFlexItem data-test-subj="plain-text-reason">{reason}</EuiFlexItem>}
</>
)}
</EuiFlexGroup>
);
},
width: '60%',
},
],
[ActionTitle, events, leadingControlColumns, rowRenderers, appId]
[ActionTitle, events, leadingControlColumns, appId, getRowRenderer, rowRenderers, timelineId]
);
const handleTableChange = useCallback(

View file

@ -13,10 +13,15 @@ import {
EuiTitle,
EuiTextColor,
} from '@elastic/eui';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { ALERTS_TABLE_VIEW_SELECTION_KEY } from '../../helpers';
const storage = new Storage(localStorage);
export type ViewSelection = 'gridView' | 'eventRenderedView';
const ContainerEuiSelectable = styled.div`
@ -51,6 +56,8 @@ const SummaryViewSelectorComponent = ({ viewSelected, onViewChange }: SummaryVie
const onChangeSelectable = useCallback(
(opts: EuiSelectableOption[]) => {
const selected = opts.filter((i) => i.checked === 'on');
storage.set(ALERTS_TABLE_VIEW_SELECTION_KEY, selected[0]?.key ?? 'gridView');
if (selected.length > 0) {
onViewChange((selected[0]?.key ?? 'gridView') as ViewSelection);
}

View file

@ -7,10 +7,18 @@
import { cloneDeep } from 'lodash/fp';
import { Filter, EsQueryConfig, FilterStateStore } from '@kbn/es-query';
import { DataProviderType } from '../../../common/types/timeline';
import { mockBrowserFields, mockDataProviders, mockIndexPattern } from '../../mock';
import { buildGlobalQuery, combineQueries, resolverIsShowing, showGlobalFilters } from './helpers';
import { DataProviderType, TimelineId } from '../../../common/types/timeline';
import {
buildGlobalQuery,
combineQueries,
getDefaultViewSelection,
isSelectableView,
isViewSelection,
resolverIsShowing,
showGlobalFilters,
} from './helpers';
import { mockBrowserFields, mockDataProviders, mockIndexPattern } from '../../mock';
const cleanUpKqlQuery = (str: string) => str.replace(/\n/g, '').replace(/\s\s+/g, ' ');
@ -557,4 +565,101 @@ describe('Combined Queries', () => {
expect(showGlobalFilters({ globalFullScreen: false, graphEventId: '' })).toBe(true);
});
});
describe('view selection', () => {
const validViewSelections = ['gridView', 'eventRenderedView'];
const invalidViewSelections = [
'gRiDvIeW',
'EvEnTrEnDeReDvIeW',
'anything else',
'',
1234,
{},
undefined,
null,
];
const selectableViews: TimelineId[] = [
TimelineId.detectionsPage,
TimelineId.detectionsRulesDetailsPage,
];
const exampleNonSelectableViews: string[] = [
TimelineId.casePage,
TimelineId.hostsPageEvents,
TimelineId.usersPageEvents,
'foozle',
'',
];
describe('isSelectableView', () => {
selectableViews.forEach((timelineId) => {
test(`it returns true (for selectable view) timelineId ${timelineId}`, () => {
expect(isSelectableView(timelineId)).toBe(true);
});
});
exampleNonSelectableViews.forEach((timelineId) => {
test(`it returns false (for NON-selectable view) timelineId ${timelineId}`, () => {
expect(isSelectableView(timelineId)).toBe(false);
});
});
});
describe('isViewSelection', () => {
validViewSelections.forEach((value) => {
test(`it returns true when value is valid: ${value}`, () => {
expect(isViewSelection(value)).toBe(true);
});
});
invalidViewSelections.forEach((value) => {
test(`it returns false when value is INvalid: ${value}`, () => {
expect(isViewSelection(value)).toBe(false);
});
});
});
describe('getDefaultViewSelection', () => {
describe('NON-selectable views', () => {
exampleNonSelectableViews.forEach((timelineId) => {
describe('given valid values', () => {
validViewSelections.forEach((value) => {
test(`it ALWAYS returns 'gridView' for NON-selectable timelineId ${timelineId}, with valid value: ${value}`, () => {
expect(getDefaultViewSelection({ timelineId, value })).toEqual('gridView');
});
});
});
describe('given INvalid values', () => {
invalidViewSelections.forEach((value) => {
test(`it ALWAYS returns 'gridView' for NON-selectable timelineId ${timelineId}, with INvalid value: ${value}`, () => {
expect(getDefaultViewSelection({ timelineId, value })).toEqual('gridView');
});
});
});
});
});
});
describe('selectable views', () => {
selectableViews.forEach((timelineId) => {
describe('given valid values', () => {
validViewSelections.forEach((value) => {
test(`it returns ${value} for selectable timelineId ${timelineId}, with valid value: ${value}`, () => {
expect(getDefaultViewSelection({ timelineId, value })).toEqual(value);
});
});
});
describe('given INvalid values', () => {
invalidViewSelections.forEach((value) => {
test(`it ALWAYS returns 'gridView' for selectable timelineId ${timelineId}, with INvalid value: ${value}`, () => {
expect(getDefaultViewSelection({ timelineId, value })).toEqual('gridView');
});
});
});
});
});
});
});

View file

@ -17,8 +17,9 @@ import {
stopPropagationAndPreventDefault,
} from '../../../common/utils/accessibility';
import type { BrowserFields } from '../../../common/search_strategy/index_fields';
import { DataProviderType, EXISTS_OPERATOR } from '../../../common/types/timeline';
import { DataProviderType, EXISTS_OPERATOR, TimelineId } from '../../../common/types/timeline';
import type { DataProvider, DataProvidersAnd } from '../../../common/types/timeline';
import type { ViewSelection } from './event_rendered_view/selector';
import { convertToBuildEsQuery, escapeQueryValue } from '../utils/keury';
import { EVENTS_TABLE_CLASS_NAME } from './styles';
@ -376,3 +377,29 @@ export const focusUtilityBarAction = (containerElement: HTMLElement | null) => {
export const resetKeyboardFocus = () => {
document.querySelector<HTMLAnchorElement>('header.headerGlobalNav a.euiHeaderLogo')?.focus();
};
export const isSelectableView = (timelineId: string): boolean =>
timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage;
export const isViewSelection = (value: unknown): value is ViewSelection =>
value === 'gridView' || value === 'eventRenderedView';
/** always returns a valid default `ViewSelection` */
export const getDefaultViewSelection = ({
timelineId,
value,
}: {
timelineId: string;
value: unknown;
}): ViewSelection => {
const defaultViewSelection = 'gridView';
if (!isSelectableView(timelineId)) {
return defaultViewSelection;
} else {
return isViewSelection(value) ? value : defaultViewSelection;
}
};
/** This local storage key stores the `Grid / Event rendered view` selection */
export const ALERTS_TABLE_VIEW_SELECTION_KEY = 'securitySolution.alerts.table.view-selection';

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
@ -39,8 +40,14 @@ import type {
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { defaultHeaders } from '../body/column_headers/default_headers';
import { getCombinedFilterQuery, resolverIsShowing } from '../helpers';
import {
ALERTS_TABLE_VIEW_SELECTION_KEY,
getCombinedFilterQuery,
getDefaultViewSelection,
resolverIsShowing,
} from '../helpers';
import { tGridActions, tGridSelectors } from '../../../store/t_grid';
import { Ecs } from '../../../../common/ecs';
import { useTimelineEvents, InspectResponse, Refetch } from '../../../container';
import { StatefulBody } from '../body';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexGroup, UpdatedFlexItem } from '../styles';
@ -49,6 +56,8 @@ import { InspectButton, InspectButtonContainer } from '../../inspect';
import { SummaryViewSelector, ViewSelection } from '../event_rendered_view/selector';
import { TGridLoading, TGridEmpty, TimelineContext } from '../shared';
const storage = new Storage(localStorage);
const TitleText = styled.span`
margin-right: 12px;
`;
@ -109,6 +118,13 @@ export interface TGridIntegratedProps {
fieldBrowserOptions?: FieldBrowserOptions;
filters: Filter[];
filterStatus?: AlertStatus;
getRowRenderer?: ({
data,
rowRenderers,
}: {
data: Ecs;
rowRenderers: RowRenderer[];
}) => RowRenderer | null;
globalFullScreen: boolean;
// If truthy, the graph viewer (Resolver) is showing
graphEventId?: string;
@ -154,6 +170,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
fieldBrowserOptions,
filters,
filterStatus,
getRowRenderer,
globalFullScreen,
graphEventId,
graphOverlay = null,
@ -182,7 +199,10 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
const columnsHeader = isEmpty(columns) ? defaultHeaders : columns;
const { uiSettings } = useKibana<CoreStart>().services;
const [tableView, setTableView] = useState<ViewSelection>('gridView');
const [tableView, setTableView] = useState<ViewSelection>(
getDefaultViewSelection({ timelineId: id, value: storage.get(ALERTS_TABLE_VIEW_SELECTION_KEY) })
);
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const { queryFields, title } = useDeepEqualSelector((state) =>
getManageTimeline(state, id ?? '')
@ -352,6 +372,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
filterQuery={filterQuery}
filters={filters}
filterStatus={filterStatus}
getRowRenderer={getRowRenderer}
hasAlertsCrud={hasAlertsCrud}
id={id}
indexNames={indexNames}

View file

@ -15,9 +15,10 @@ import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { Ecs } from '../../../../common/ecs';
import { Direction, EntityType } from '../../../../common/search_strategy';
import { TGridCellAction, TimelineTabs } from '../../../../common/types/timeline';
import type {
CellValueElementProps,
ColumnHeaderOptions,
@ -87,6 +88,13 @@ export interface TGridStandaloneProps {
filters: Filter[];
footerText: React.ReactNode;
filterStatus?: AlertStatus;
getRowRenderer?: ({
data,
rowRenderers,
}: {
data: Ecs;
rowRenderers: RowRenderer[];
}) => RowRenderer | null;
hasAlertsCrudPermissions: ({
ruleConsumer,
ruleProducer,
@ -129,6 +137,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
filters,
footerText,
filterStatus,
getRowRenderer,
hasAlertsCrudPermissions,
indexNames,
itemsPerPage,
@ -352,6 +361,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
defaultCellActions={defaultCellActions}
disabledCellActions={disabledCellActions}
filterQuery={filterQuery}
getRowRenderer={getRowRenderer}
hasAlertsCrud={hasAlertsCrud}
hasAlertsCrudPermissions={hasAlertsCrudPermissions}
id={STANDALONE_ID}

View file

@ -142,5 +142,6 @@ export const eventRenderedProps: EventRenderedViewProps = {
pageSize: 10,
pageSizeOptions: [10, 25, 50, 100],
rowRenderers: [],
timelineId: TimelineId.detectionsPage,
totalItemCount: 100,
};

View file

@ -193,7 +193,7 @@ export default ({ getService }: FtrProviderContext) => {
'kibana.alert.workflow_status': 'open',
'kibana.alert.depth': 1,
'kibana.alert.reason':
'authentication event by root on zeek-newyork-sha-aa8df15 created high alert Query with a rule id.',
'authentication event with source 8.42.77.171 by root on zeek-newyork-sha-aa8df15 created high alert Query with a rule id.',
'kibana.alert.severity': 'high',
'kibana.alert.risk_score': 55,
'kibana.alert.rule.parameters': {

View file

@ -285,7 +285,7 @@ export default ({ getService }: FtrProviderContext) => {
[ALERT_ORIGINAL_EVENT_MODULE]: 'auditd',
[ALERT_ORIGINAL_TIME]: fullSignal[ALERT_ORIGINAL_TIME],
[ALERT_REASON]:
'user-login event by root on zeek-sensor-amsterdam created high alert Query with a rule id.',
'user-login event with source 46.101.47.213 by root on zeek-sensor-amsterdam created high alert Query with a rule id.',
[ALERT_RULE_UUID]: fullSignal[ALERT_RULE_UUID],
[ALERT_STATUS]: 'active',
[ALERT_UUID]: fullSignal[ALERT_UUID],