[SecuritySolution] Add alert prevalence column to highlighted fields table (#127599)

Co-authored-by: Jan Monschke <jan.monschke@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Michael Olorunnisola 2022-03-17 09:21:06 -04:00 committed by GitHub
parent 067f4c2ede
commit 26a47d069f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 919 additions and 167 deletions

View file

@ -5,56 +5,15 @@
* 2.0.
*/
import { EuiBasicTableColumn } from '@elastic/eui';
import React, { useMemo } from 'react';
import { BrowserFields } from '../../../../common/search_strategy/index_fields';
import { SummaryView } from './summary_view';
import { AlertSummaryRow, getSummaryColumns, SummaryRow } from './helpers';
import { ActionCell } from './table/action_cell';
import { FieldValueCell } from './table/field_value_cell';
import { TimelineId } from '../../../../common/types';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { getSummaryRows } from './get_alert_summary_rows';
const getDescription = ({
data,
eventId,
fieldFromBrowserField,
isDraggable,
linkValue,
timelineId,
values,
}: AlertSummaryRow['description']) => (
<>
<FieldValueCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
isDraggable={isDraggable}
values={values}
/>
{timelineId !== TimelineId.active && (
<ActionCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
timelineId={timelineId}
values={values}
applyWidthAndPadding={false}
/>
)}
</>
);
const summaryColumns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns(getDescription);
const AlertSummaryViewComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
@ -69,14 +28,7 @@ const AlertSummaryViewComponent: React.FC<{
[browserFields, data, eventId, isDraggable, timelineId]
);
return (
<SummaryView
summaryColumns={summaryColumns}
summaryRows={summaryRows}
title={title}
goToTable={goToTable}
/>
);
return <SummaryView rows={summaryRows} title={title} goToTable={goToTable} />;
};
export const AlertSummaryView = React.memo(AlertSummaryViewComponent);

View file

@ -17,14 +17,19 @@ import {
} from '@elastic/eui';
import { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment, getFirstSeen } from './helpers';
import {
getEnrichmentIdentifiers,
isInvestigationTimeEnrichment,
getFirstSeen,
ThreatDetailsRow,
} from './helpers';
import { EnrichmentButtonContent } from './enrichment_button_content';
import { ThreatSummaryTitle } from './threat_summary_title';
import { InspectButton } from '../../inspect';
import { QUERY_ID } from '../../../containers/cti/event_enrichment';
import * as i18n from './translations';
import { StyledEuiInMemoryTable } from '../summary_view';
import { ThreatSummaryTable } from './threat_summary_table';
import { REFERENCE } from '../../../../../common/cti/constants';
import { getSummaryColumns, SummaryRow, ThreatDetailsRow } from '../helpers';
import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants';
import { getFirstElement } from '../../../../../common/utils/data_retrieval';
@ -65,7 +70,21 @@ const ThreatDetailsDescription: React.FC<ThreatDetailsRow['description']> = ({
);
};
const columns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns(ThreatDetailsDescription);
const columns: Array<EuiBasicTableColumn<ThreatDetailsRow>> = [
{
field: 'title',
truncateText: false,
render: ThreatSummaryTitle,
width: '220px',
name: '',
},
{
field: 'description',
truncateText: false,
render: ThreatDetailsDescription,
name: '',
},
];
const buildThreatDetailsItems = (enrichment: CtiEnrichment) =>
Object.keys(enrichment)
@ -107,7 +126,7 @@ const EnrichmentAccordion: React.FC<{
)
}
>
<StyledEuiInMemoryTable
<ThreatSummaryTable
columns={columns}
compressed
data-test-subj={`threat-details-view-${index}`}

View file

@ -126,3 +126,11 @@ export const getFirstSeen = (enrichment: CtiEnrichment): number => {
const firstSeenDate = Date.parse(firstSeenValue ?? 'no date');
return Number.isInteger(firstSeenDate) ? firstSeenDate : new Date(-1).valueOf();
};
export interface ThreatDetailsRow {
title: string;
description: {
fieldName: string;
value: string;
};
}

View file

@ -0,0 +1,19 @@
/*
* 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 styled, { AnyStyledComponent } from 'styled-components';
import { EuiInMemoryTable } from '@elastic/eui';
export const ThreatSummaryTable = styled(EuiInMemoryTable as unknown as AnyStyledComponent)`
.euiTableHeaderCell,
.euiTableRowCell {
border: none;
}
.euiTableHeaderCell .euiTableCellContent {
padding: 0;
}
`;

View file

@ -0,0 +1,21 @@
/*
* 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 styled from 'styled-components';
import React from 'react';
import { EuiTitle } from '@elastic/eui';
const StyledH5 = styled.h5`
line-height: 1.7rem;
`;
export const ThreatSummaryTitle = (title: string) => (
<EuiTitle size="xxxs">
<StyledH5>{title}</StyledH5>
</EuiTitle>
);
ThreatSummaryTitle.displayName = 'ThreatSummaryTitle';

View file

@ -178,7 +178,7 @@ const EventDetailsComponent: React.FC<Props> = ({
browserFields,
isDraggable,
timelineId,
title: i18n.HIGHLIGHTES_FIELDS,
title: i18n.HIGHLIGHTED_FIELDS,
}}
goToTable={goToTableTab}
/>

View file

@ -18,7 +18,7 @@ import {
} from '../../../detections/components/alerts_table/translations';
import { ALERT_THRESHOLD_RESULT } from '../../../../common/field_maps/field_names';
import { AGENT_STATUS_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants';
import { getEnrichedFieldInfo, SummaryRow } from './helpers';
import { getEnrichedFieldInfo, AlertSummaryRow } from './helpers';
import { EventSummaryField, EnrichedFieldInfo } from './types';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
@ -253,7 +253,7 @@ export const getSummaryRows = ({
});
return data != null
? tableFields.reduce<SummaryRow[]>((acc, field) => {
? tableFields.reduce<AlertSummaryRow[]>((acc, field) => {
const item = data.find(
(d) => d.field === field.id || (field.legacyId && d.field === field.legacyId)
);

View file

@ -7,9 +7,6 @@
import { get, getOr, isEmpty, uniqBy } from 'lodash/fp';
import styled from 'styled-components';
import React from 'react';
import { EuiBasicTableColumn, EuiTitle } from '@elastic/eui';
import {
elementOrChildrenHasFocus,
getFocusedDataColindexCell,
@ -62,16 +59,6 @@ export interface AlertSummaryRow {
};
}
export interface ThreatDetailsRow {
title: string;
description: {
fieldName: string;
value: string;
};
}
export type SummaryRow = AlertSummaryRow | ThreatDetailsRow;
export const getColumnHeaderFromBrowserField = ({
browserField,
width = DEFAULT_COLUMN_MIN_WIDTH,
@ -194,40 +181,6 @@ export const onEventDetailsTabKeyPressed = ({
}
};
const StyledH5 = styled.h5`
line-height: 1.7rem;
`;
const getTitle = (title: string) => (
<EuiTitle size="xxxs">
<StyledH5>{title}</StyledH5>
</EuiTitle>
);
getTitle.displayName = 'getTitle';
export const getSummaryColumns = (
DescriptionComponent:
| React.FC<AlertSummaryRow['description']>
| React.FC<ThreatDetailsRow['description']>
): Array<EuiBasicTableColumn<SummaryRow>> => {
return [
{
field: 'title',
truncateText: false,
render: getTitle,
width: '220px',
name: '',
},
{
className: 'flyoutOverviewDescription',
field: 'description',
truncateText: false,
render: DescriptionComponent,
name: '',
},
];
};
export function getEnrichedFieldInfo({
browserFields,
contextId,

View file

@ -153,12 +153,20 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
<div
data-test-subj="hover-actions-filter-for"
>
Filter button
<span
data-test-subj="test-filter-for"
>
Filter button
</span>
</div>
<div
data-test-subj="hover-actions-filter-out"
>
Filter out button
<span
data-test-subj="test-filter-out"
>
Filter out button
</span>
</div>
<div
data-test-subj="more-actions-kibana.alert.workflow_status"
@ -214,12 +222,20 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
<div
data-test-subj="hover-actions-filter-for"
>
Filter button
<span
data-test-subj="test-filter-for"
>
Filter button
</span>
</div>
<div
data-test-subj="hover-actions-filter-out"
>
Filter out button
<span
data-test-subj="test-filter-out"
>
Filter out button
</span>
</div>
<div
data-test-subj="more-actions-kibana.alert.risk_score"
@ -287,12 +303,20 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
<div
data-test-subj="hover-actions-filter-for"
>
Filter button
<span
data-test-subj="test-filter-for"
>
Filter button
</span>
</div>
<div
data-test-subj="hover-actions-filter-out"
>
Filter out button
<span
data-test-subj="test-filter-out"
>
Filter out button
</span>
</div>
<div
data-test-subj="more-actions-kibana.alert.rule.name"

View file

@ -0,0 +1,95 @@
/*
* 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 { BrowserField } from '../../containers/source';
import { TestProviders } from '../../mock';
import { EventFieldsData } from './types';
import { SummaryView } from './summary_view';
import { TimelineId } from '../../../../common/types';
import { AlertSummaryRow } from './helpers';
jest.mock('../../lib/kibana');
const eventId = 'TUWyf3wBFCFU0qRJTauW';
const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'];
const hostIpFieldFromBrowserField: BrowserField = {
aggregatable: true,
category: 'host',
description: 'Host ip addresses.',
example: '127.0.0.1',
fields: {},
format: '',
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
name: 'host.ip',
readFromDocValues: false,
searchable: true,
type: 'ip',
};
const hostIpData: EventFieldsData = {
...hostIpFieldFromBrowserField,
ariaRowindex: 35,
field: 'host.ip',
fields: {},
format: '',
isObjectArray: false,
originalValue: [...hostIpValues],
values: [...hostIpValues],
};
const enrichedHostIpData: AlertSummaryRow['description'] = {
data: { ...hostIpData },
eventId,
fieldFromBrowserField: { ...hostIpFieldFromBrowserField },
isDraggable: false,
timelineId: TimelineId.test,
values: [...hostIpValues],
};
jest.mock('../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: () => ({
loading: false,
count: 1,
error: false,
}),
}));
describe('Summary View', () => {
describe('when no data is provided', () => {
test('should show an empty table', () => {
render(
<TestProviders>
<SummaryView goToTable={jest.fn()} title="Test Summary View" rows={[]} />
</TestProviders>
);
expect(screen.getByText('No items found')).toBeInTheDocument();
});
});
describe('when data is provided', () => {
test('should show the data', () => {
const sampleRows: AlertSummaryRow[] = [
{
title: hostIpData.field,
description: enrichedHostIpData,
},
];
render(
<TestProviders>
<SummaryView goToTable={jest.fn()} title="Test Summary View" rows={sampleRows} />
</TestProviders>
);
expect(screen.getByText(hostIpData.field)).toBeInTheDocument();
hostIpValues.forEach((ipValue) => {
expect(screen.getByText(ipValue)).toBeInTheDocument();
});
});
});
});

View file

@ -6,7 +6,6 @@
*/
import {
EuiInMemoryTable,
EuiBasicTableColumn,
EuiLink,
EuiTitle,
@ -14,51 +13,60 @@ import {
EuiFlexItem,
EuiSpacer,
EuiText,
EuiIconTip,
} from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { SummaryRow } from './helpers';
import type { AlertSummaryRow } from './helpers';
import * as i18n from './translations';
import { VIEW_ALL_FIELDS } from './translations';
import { SummaryTable } from './table/summary_table';
import { SummaryValueCell } from './table/summary_value_cell';
import { PrevalenceCellRenderer } from './table/prevalence_cell';
export const Indent = styled.div`
padding: 0 12px;
`;
const summaryColumns: Array<EuiBasicTableColumn<AlertSummaryRow>> = [
{
field: 'title',
truncateText: false,
name: i18n.HIGHLIGHTED_FIELDS_FIELD,
textOnly: true,
},
{
field: 'description',
truncateText: false,
render: SummaryValueCell,
name: i18n.HIGHLIGHTED_FIELDS_VALUE,
},
{
field: 'description',
truncateText: true,
render: PrevalenceCellRenderer,
name: (
<>
{i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE}{' '}
<EuiIconTip
type="iInCircle"
color="subdued"
title="Alert Prevalence"
content={<span>{i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE_TOOLTIP}</span>}
/>
</>
),
align: 'right',
width: '130px',
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
.euiTableHeaderCell,
.euiTableRowCell {
border: none;
}
.euiTableHeaderCell .euiTableCellContent {
padding: 0;
}
const rowProps = {
// Class name for each row. On hover of a row, all actions for that row will be shown.
className: 'flyoutTableHoverActions',
};
.flyoutOverviewDescription {
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
}
`;
export const SummaryViewComponent: React.FC<{
const SummaryViewComponent: React.FC<{
goToTable: () => void;
title: string;
summaryColumns: Array<EuiBasicTableColumn<SummaryRow>>;
summaryRows: SummaryRow[];
dataTestSubj?: string;
}> = ({ goToTable, summaryColumns, summaryRows, dataTestSubj = 'summary-view', title }) => {
rows: AlertSummaryRow[];
}> = ({ goToTable, rows, title }) => {
return (
<div>
<EuiFlexGroup>
@ -74,14 +82,13 @@ export const SummaryViewComponent: React.FC<{
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<Indent>
<StyledEuiInMemoryTable
data-test-subj={dataTestSubj}
items={summaryRows}
columns={summaryColumns}
compressed
/>
</Indent>
<SummaryTable
data-test-subj="summary-view"
items={rows}
columns={summaryColumns}
rowProps={rowProps}
compressed
/>
</div>
);
};

View file

@ -19,6 +19,7 @@ interface Props extends EnrichedFieldInfo {
getLinkValue?: (field: string) => string | null;
onFilterAdded?: () => void;
toggleColumn?: (column: ColumnHeaderOptions) => void;
hideAddToTimeline?: boolean;
}
export const ActionCell: React.FC<Props> = React.memo(
@ -34,6 +35,7 @@ export const ActionCell: React.FC<Props> = React.memo(
timelineId,
toggleColumn,
values,
hideAddToTimeline,
}) => {
const actionCellConfig = useActionCellDataProvider({
contextId,
@ -69,6 +71,7 @@ export const ActionCell: React.FC<Props> = React.memo(
dataProvider={actionCellConfig?.dataProvider}
enableOverflowButton={true}
field={data.field}
hideAddToTimeline={hideAddToTimeline}
isObjectArray={data.isObjectArray}
onFilterAdded={onFilterAdded}
ownFocus={hoverActionsOwnFocus}

View file

@ -0,0 +1,82 @@
/*
* 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 { BrowserField } from '../../../containers/source';
import { AddToTimelineCellRenderer } from './add_to_timeline_cell';
import { TestProviders } from '../../../mock';
import { EventFieldsData } from '../types';
import { TimelineId } from '../../../../../common/types';
jest.mock('../../../lib/kibana');
const eventId = 'TUWyf3wBFCFU0qRJTauW';
const hostIpFieldFromBrowserField: BrowserField = {
aggregatable: true,
category: 'host',
description: 'Host ip addresses.',
example: '127.0.0.1',
fields: {},
format: '',
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
name: 'host.ip',
readFromDocValues: false,
searchable: true,
type: 'ip',
};
const hostIpData: EventFieldsData = {
...hostIpFieldFromBrowserField,
ariaRowindex: 35,
field: 'host.ip',
fields: {},
format: '',
isObjectArray: false,
originalValue: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'],
values: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'],
};
describe('AddToTimelineCellRenderer', () => {
describe('When all props are provided', () => {
test('it should display the add to timeline button', () => {
render(
<TestProviders>
<AddToTimelineCellRenderer
data={hostIpData}
eventId={eventId}
fieldFromBrowserField={hostIpFieldFromBrowserField}
linkValue={undefined}
timelineId={TimelineId.test}
values={hostIpData.values}
/>
</TestProviders>
);
expect(screen.getByTestId('test-add-to-timeline')).toBeInTheDocument();
});
});
describe('When browser field data necessary for timeline is unavailable', () => {
test('it should not render', () => {
render(
<TestProviders>
<AddToTimelineCellRenderer
data={hostIpData}
eventId={eventId}
fieldFromBrowserField={undefined}
linkValue={undefined}
timelineId={TimelineId.test}
values={hostIpData.values}
/>
</TestProviders>
);
expect(screen.queryByTestId('test-add-to-timeline')).toBeNull();
});
});
});

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { isEmpty } from 'lodash';
import { useKibana } from '../../../lib/kibana';
import { AlertSummaryRow } from '../helpers';
import { useActionCellDataProvider } from './use_action_cell_data_provider';
const AddToTimelineCell = React.memo<AlertSummaryRow['description']>(
({ data, eventId, fieldFromBrowserField, linkValue, timelineId, values }) => {
const kibana = useKibana();
const { timelines } = kibana.services;
const { getAddToTimelineButton } = timelines.getHoverActions();
const actionCellConfig = useActionCellDataProvider({
contextId: timelineId,
eventId,
field: data.field,
fieldFormat: data.format,
fieldFromBrowserField,
fieldType: data.type,
isObjectArray: data.isObjectArray,
linkValue,
values,
});
const showButton = values != null && !isEmpty(actionCellConfig?.dataProvider);
if (showButton) {
return getAddToTimelineButton({
dataProvider: actionCellConfig?.dataProvider,
field: data.field,
ownFocus: true,
});
} else {
return null;
}
}
);
AddToTimelineCell.displayName = 'AddToTimelineCell';
export const AddToTimelineCellRenderer = (props: AlertSummaryRow['description']) => (
<AddToTimelineCell {...props} />
);

View file

@ -7,6 +7,7 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { CSSObject } from 'styled-components';
import { BrowserField } from '../../../containers/source';
import { OverflowField } from '../../tables/helpers';
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
@ -21,6 +22,7 @@ export interface FieldValueCellProps {
getLinkValue?: (field: string) => string | null;
isDraggable?: boolean;
linkValue?: string | null | undefined;
style?: CSSObject | undefined;
values: string[] | null | undefined;
}
@ -33,6 +35,7 @@ export const FieldValueCell = React.memo(
getLinkValue,
isDraggable = false,
linkValue,
style,
values,
}: FieldValueCellProps) => {
return (
@ -41,6 +44,7 @@ export const FieldValueCell = React.memo(
data-test-subj={`event-field-${data.field}`}
direction="column"
gutterSize="none"
style={style}
>
{values != null &&
values.map((value, i) => {

View file

@ -0,0 +1,110 @@
/*
* 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 { BrowserField } from '../../../containers/source';
import { PrevalenceCellRenderer } from './prevalence_cell';
import { TestProviders } from '../../../mock';
import { EventFieldsData } from '../types';
import { TimelineId } from '../../../../../common/types';
import { AlertSummaryRow } from '../helpers';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
jest.mock('../../../lib/kibana');
jest.mock('../../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: jest.fn(),
}));
const mockUseAlertPrevalence = useAlertPrevalence as jest.Mock;
const eventId = 'TUWyf3wBFCFU0qRJTauW';
const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'];
const hostIpFieldFromBrowserField: BrowserField = {
aggregatable: true,
category: 'host',
description: 'Host ip addresses.',
example: '127.0.0.1',
fields: {},
format: '',
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
name: 'host.ip',
readFromDocValues: false,
searchable: true,
type: 'ip',
};
const hostIpData: EventFieldsData = {
...hostIpFieldFromBrowserField,
ariaRowindex: 35,
field: 'host.ip',
fields: {},
format: '',
isObjectArray: false,
originalValue: [...hostIpValues],
values: [...hostIpValues],
};
const enrichedHostIpData: AlertSummaryRow['description'] = {
data: { ...hostIpData },
eventId,
fieldFromBrowserField: { ...hostIpFieldFromBrowserField },
isDraggable: false,
timelineId: TimelineId.test,
values: [...hostIpValues],
};
describe('PrevalenceCellRenderer', () => {
describe('When data is loading', () => {
test('it should show the loading spinner', async () => {
mockUseAlertPrevalence.mockImplementation(() => ({
loading: true,
count: 123,
error: true,
}));
const { container } = render(
<TestProviders>
<PrevalenceCellRenderer {...enrichedHostIpData} />
</TestProviders>
);
expect(container.getElementsByClassName('euiLoadingSpinner')).toHaveLength(1);
});
});
describe('When an error was returned', () => {
test('it should return null', async () => {
mockUseAlertPrevalence.mockImplementation(() => ({
loading: false,
count: 123,
error: true,
}));
const { container } = render(
<TestProviders>
<PrevalenceCellRenderer {...enrichedHostIpData} />
</TestProviders>
);
expect(container.getElementsByClassName('euiLoadingSpinner')).toHaveLength(0);
expect(screen.queryByText('123')).toBeNull();
});
});
describe('When an actual count is returned', () => {
test('it should show the count', async () => {
mockUseAlertPrevalence.mockImplementation(() => ({
loading: false,
count: 123,
error: false,
}));
const { container } = render(
<TestProviders>
<PrevalenceCellRenderer {...enrichedHostIpData} />
</TestProviders>
);
expect(container.getElementsByClassName('euiLoadingSpinner')).toHaveLength(0);
expect(screen.queryByText('123')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 { EuiLoadingSpinner } from '@elastic/eui';
import { AlertSummaryRow } from '../helpers';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
const PrevalenceCell = React.memo<AlertSummaryRow['description']>(
({ data, values, timelineId }) => {
const { loading, count, error } = useAlertPrevalence({
field: data.field,
timelineId,
value: values,
signalIndexName: null,
});
if (loading) {
return <EuiLoadingSpinner />;
} else if (error) {
return null;
}
return <>{count}</>;
}
);
PrevalenceCell.displayName = 'PrevalenceCell';
export const PrevalenceCellRenderer = (data: AlertSummaryRow['description']) => {
return <PrevalenceCell {...data} />;
};

View file

@ -0,0 +1,31 @@
/*
* 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 styled, { AnyStyledComponent } from 'styled-components';
import { EuiInMemoryTable } from '@elastic/eui';
export const SummaryTable = styled(EuiInMemoryTable as unknown as AnyStyledComponent)`
.timelines__hoverActionButton {
opacity: 0;
}
.flyoutTableHoverActions {
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
}
`;

View file

@ -0,0 +1,79 @@
/*
* 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 { BrowserField } from '../../../containers/source';
import { SummaryValueCell } from './summary_value_cell';
import { TestProviders } from '../../../mock';
import { EventFieldsData } from '../types';
import { AlertSummaryRow } from '../helpers';
import { TimelineId } from '../../../../../common/types';
jest.mock('../../../lib/kibana');
const eventId = 'TUWyf3wBFCFU0qRJTauW';
const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'];
const hostIpFieldFromBrowserField: BrowserField = {
aggregatable: true,
category: 'host',
description: 'Host ip addresses.',
example: '127.0.0.1',
fields: {},
format: '',
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
name: 'host.ip',
readFromDocValues: false,
searchable: true,
type: 'ip',
};
const hostIpData: EventFieldsData = {
...hostIpFieldFromBrowserField,
ariaRowindex: 35,
field: 'host.ip',
fields: {},
format: '',
isObjectArray: false,
originalValue: [...hostIpValues],
values: [...hostIpValues],
};
const enrichedHostIpData: AlertSummaryRow['description'] = {
data: { ...hostIpData },
eventId,
fieldFromBrowserField: { ...hostIpFieldFromBrowserField },
isDraggable: false,
timelineId: TimelineId.test,
values: [...hostIpValues],
};
describe('SummaryValueCell', () => {
test('it should render', async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedHostIpData} />
</TestProviders>
);
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
expect(screen.getAllByTestId('test-filter-for')).toHaveLength(1);
expect(screen.getAllByTestId('test-filter-out')).toHaveLength(1);
});
describe('When in the timeline flyout with timelineId active', () => {
test('it should not render the default hover actions', async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedHostIpData} timelineId={TimelineId.active} />
</TestProviders>
);
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
expect(screen.queryByTestId('test-filter-for')).toBeNull();
expect(screen.queryByTestId('test-filter-out')).toBeNull();
});
});
});

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ActionCell } from './action_cell';
import { FieldValueCell } from './field_value_cell';
import { AlertSummaryRow } from '../helpers';
import { TimelineId } from '../../../../../common/types';
export const SummaryValueCell: React.FC<AlertSummaryRow['description']> = ({
data,
eventId,
fieldFromBrowserField,
isDraggable,
linkValue,
timelineId,
values,
}) => (
<>
<FieldValueCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
isDraggable={isDraggable}
style={{ flexGrow: 0 }}
values={values}
/>
{timelineId !== TimelineId.active && (
<ActionCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
timelineId={timelineId}
values={values}
applyWidthAndPadding={false}
hideAddToTimeline={false}
/>
)}
</>
);
SummaryValueCell.displayName = 'SummaryValueCell';

View file

@ -92,15 +92,17 @@ export const useActionCellDataProvider = ({
} else if (fieldType === IP_FIELD_TYPE) {
id = `formatted-ip-data-provider-${contextId}-${field}-${value}-${eventId}`;
if (isString(value) && !isEmpty(value)) {
let addresses = value;
try {
const addresses = JSON.parse(value);
if (isArray(addresses)) {
valueAsString = addresses.join(',');
addresses.forEach((ip) => memo.dataProvider.push(getDataProvider(field, id, ip)));
}
addresses = JSON.parse(value);
} catch (_) {
// Default to keeping the existing string value
}
if (isArray(addresses)) {
valueAsString = addresses.join(',');
addresses.forEach((ip) => memo.dataProvider.push(getDataProvider(field, id, ip)));
}
memo.dataProvider.push(getDataProvider(field, id, addresses));
memo.stringValues.push(valueAsString);
return memo;
}

View file

@ -22,13 +22,42 @@ export const OVERVIEW = i18n.translate('xpack.securitySolution.alertDetails.over
defaultMessage: 'Overview',
});
export const HIGHLIGHTES_FIELDS = i18n.translate(
export const HIGHLIGHTED_FIELDS = i18n.translate(
'xpack.securitySolution.alertDetails.overview.highlightedFields',
{
defaultMessage: 'Highlighted fields',
}
);
export const HIGHLIGHTED_FIELDS_FIELD = i18n.translate(
'xpack.securitySolution.alertDetails.overview.highlightedFields.field',
{
defaultMessage: 'Field',
}
);
export const HIGHLIGHTED_FIELDS_VALUE = i18n.translate(
'xpack.securitySolution.alertDetails.overview.highlightedFields.value',
{
defaultMessage: 'Value',
}
);
export const HIGHLIGHTED_FIELDS_ALERT_PREVALENCE = i18n.translate(
'xpack.securitySolution.alertDetails.overview.highlightedFields.alertPrevalence',
{
defaultMessage: 'Alert Prevalence',
}
);
export const HIGHLIGHTED_FIELDS_ALERT_PREVALENCE_TOOLTIP = i18n.translate(
'xpack.securitySolution.alertDetails.overview.highlightedFields.alertPrevalenceTooltip',
{
defaultMessage:
'The total count of alerts with the same value within the currently selected timerange. This value is not affected by additional filters.',
}
);
export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', {
defaultMessage: 'Table',
});

View file

@ -97,6 +97,7 @@ interface Props {
enableOverflowButton?: boolean;
field: string;
goGetTimelineId?: (args: boolean) => void;
hideAddToTimeline?: boolean;
hideTopN?: boolean;
isObjectArray: boolean;
onFilterAdded?: () => void;
@ -137,6 +138,7 @@ export const HoverActions: React.FC<Props> = React.memo(
field,
goGetTimelineId,
isObjectArray,
hideAddToTimeline = false,
hideTopN = false,
onFilterAdded,
ownFocus,
@ -218,6 +220,7 @@ export const HoverActions: React.FC<Props> = React.memo(
enableOverflowButton: enableOverflowButton && !isCaseView,
field,
handleHoverActionClicked,
hideAddToTimeline,
hideTopN,
isCaseView,
isObjectArray,

View file

@ -22,6 +22,7 @@ describe('useHoverActionItems', () => {
defaultFocusedButtonRef: null,
field: 'kibana.alert.rule.name',
handleHoverActionClicked: jest.fn(),
hideAddToTimeline: false,
hideTopN: false,
isCaseView: false,
isObjectArray: false,
@ -274,4 +275,21 @@ describe('useHoverActionItems', () => {
);
});
});
test('when timeline button is disabled, it should not show', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => {
const testProps = {
...defaultProps,
hideAddToTimeline: true,
};
return useHoverActionItems(testProps);
});
await waitForNextUpdate();
result.current.allActionItems.forEach((actionItem) => {
expect(actionItem.props['data-test-subj']).not.toEqual('hover-actions-add-timeline');
});
});
});
});

View file

@ -30,6 +30,7 @@ export interface UseHoverActionItemsProps {
enableOverflowButton?: boolean;
field: string;
handleHoverActionClicked: () => void;
hideAddToTimeline: boolean;
hideTopN: boolean;
isCaseView: boolean;
isObjectArray: boolean;
@ -60,6 +61,7 @@ export const useHoverActionItems = ({
field,
handleHoverActionClicked,
hideTopN,
hideAddToTimeline,
isCaseView,
isObjectArray,
isOverflowPopoverOpen,
@ -204,7 +206,7 @@ export const useHoverActionItems = ({
})}
</div>
) : null,
values != null && (draggableId != null || !isEmpty(dataProvider)) ? (
values != null && (draggableId != null || !isEmpty(dataProvider)) && !hideAddToTimeline ? (
<div data-test-subj="hover-actions-add-timeline" key="hover-actions-add-timeline">
{getAddToTimelineButton({
Component: enableOverflowButton ? EuiContextMenuItem : undefined,
@ -258,6 +260,7 @@ export const useHoverActionItems = ({
getFilterForValueButton,
getFilterOutValueButton,
handleHoverActionClicked,
hideAddToTimeline,
hideTopN,
isObjectArray,
onFilterAdded,

View file

@ -0,0 +1,147 @@
/*
* 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 { useEffect, useState } from 'react';
import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../common/constants';
import { useGlobalTime } from '../use_global_time';
import { GenericBuckets } from '../../../../common/search_strategy';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { TimelineId } from '../../../../common/types';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { inputsSelectors } from '../../store';
const ALERT_PREVALENCE_AGG = 'countOfAlertsWithSameFieldAndValue';
export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count';
interface UseAlertPrevalenceOptions {
field: string;
value: string | string[] | undefined | null;
timelineId: string;
signalIndexName: string | null;
}
interface UserAlertPrevalenceResult {
loading: boolean;
count: undefined | number;
error: boolean;
}
export const useAlertPrevalence = ({
field,
value,
timelineId,
signalIndexName,
}: UseAlertPrevalenceOptions): UserAlertPrevalenceResult => {
const timelineTime = useDeepEqualSelector((state) =>
inputsSelectors.timelineTimeRangeSelector(state)
);
const globalTime = useGlobalTime();
const { to, from } = timelineId === TimelineId.active ? timelineTime : globalTime;
const [initialQuery] = useState(() => generateAlertPrevalenceQuery(field, value, from, to));
const { loading, data, setQuery } = useQueryAlerts<{}, AlertPrevalenceAggregation>({
query: initialQuery,
indexName: signalIndexName,
});
useEffect(() => {
setQuery(generateAlertPrevalenceQuery(field, value, from, to));
}, [setQuery, field, value, from, to]);
let count: undefined | number;
if (data) {
const buckets = data.aggregations?.[ALERT_PREVALENCE_AGG]?.buckets;
if (buckets && buckets.length > 0) {
/**
* Currently for array fields like `process.args` or potentially any `ip` fields
* We show the combined count of all occurences of the value, even though those values
* could be shared across multiple documents. To make this clearer, we should separate
* these values into separate table rows
*/
count = buckets?.reduce((sum, bucket) => sum + (bucket?.doc_count ?? 0), 0);
}
}
const error = !loading && count === undefined;
return {
loading,
count,
error,
};
};
const generateAlertPrevalenceQuery = (
field: string,
value: string | string[] | undefined | null,
from: string,
to: string
) => {
const actualValue = Array.isArray(value) && value.length === 1 ? value[0] : value;
let query;
query = {
bool: {
must: {
match: {
[field]: actualValue,
},
},
filter: [
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
],
},
};
if (Array.isArray(value) && value.length > 1) {
const shouldValues = value.map((val) => ({ match: { [field]: val } }));
query = {
bool: {
minimum_should_match: 1,
must: [
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
],
should: shouldValues,
},
};
}
return {
size: 0,
aggs: {
[ALERT_PREVALENCE_AGG]: {
terms: {
field,
size: DEFAULT_MAX_TABLE_QUERY_SIZE,
},
},
},
query,
runtime_mappings: {},
};
};
export interface AlertPrevalenceAggregation {
[ALERT_PREVALENCE_AGG]: {
buckets: GenericBuckets[];
};
}

View file

@ -7,11 +7,15 @@
import React from 'react';
export const mockHoverActions = {
getAddToTimelineButton: () => <>{'Add To Timeline'}</>,
getColumnToggleButton: () => <>{'Column Toggle'}</>,
getCopyButton: () => <>{'Copy button'}</>,
getFilterForValueButton: () => <>{'Filter button'}</>,
getFilterOutValueButton: () => <>{'Filter out button'}</>,
getAddToTimelineButton: () => (
<span data-test-subj="test-add-to-timeline">{'Add To Timeline'}</span>
),
getColumnToggleButton: () => <span data-test-subj="test-column-toggle">{'Column Toggle'}</span>,
getCopyButton: () => <span data-test-subj="test-copy-button">{'Copy button'}</span>,
getFilterForValueButton: () => <span data-test-subj="test-filter-for">{'Filter button'}</span>,
getFilterOutValueButton: () => (
<span data-test-subj="test-filter-out">{'Filter out button'}</span>
),
getOverflowButton: (props: { field: string }) => (
<div data-test-subj={`more-actions-${props.field}`} {...props}>
{'Overflow button'}