mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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  _Above: The Alert Renderer in Timeline_ ### The Alerts page's _Event rendered view_  _Above: The Alert Renderer in the Alert page's Event rendered view_ ### The Alert details flyout  _Above: The Alert Renderer in the Alert details flyout_ ### The _Reason_ column popover in the Alerts page's _Grid view_  _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.  _Above: View selection is now persisted in local storage_
This commit is contained in:
parent
e7b4063057
commit
2db0664ecb
47 changed files with 1962 additions and 126 deletions
|
@ -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[];
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -59,7 +59,7 @@ export const StatefulRowRenderer = ({
|
|||
});
|
||||
|
||||
const rowRenderer = useMemo(
|
||||
() => getRowRenderer(event.ecs, rowRenderers),
|
||||
() => getRowRenderer({ data: event.ecs, rowRenderers }),
|
||||
[event.ecs, rowRenderers]
|
||||
);
|
||||
|
||||
|
|
|
@ -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,');
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
`;
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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',
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 })}
|
||||
</>
|
||||
),
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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': ['-'],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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?'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -142,5 +142,6 @@ export const eventRenderedProps: EventRenderedViewProps = {
|
|||
pageSize: 10,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
rowRenderers: [],
|
||||
timelineId: TimelineId.detectionsPage,
|
||||
totalItemCount: 100,
|
||||
};
|
||||
|
|
|
@ -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': {
|
||||
|
|
|
@ -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],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue