[Security Solution] - remove old event details flyout and panel (#187846)

This commit is contained in:
Philippe Oberti 2024-07-24 13:24:49 +02:00 committed by GitHub
parent e1277b829f
commit d0b156d15a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
135 changed files with 51 additions and 9175 deletions

View file

@ -5,26 +5,11 @@
* 2.0.
*/
type EmptyObject = Record<string | number, never>;
export enum FlowTargetSourceDest {
destination = 'destination',
source = 'source',
}
export type ExpandedEventType =
| {
panelView?: 'eventDetail';
params?: {
eventId: string;
indexName: string;
refetch?: () => void;
};
}
| EmptyObject;
export type ExpandedDetailType = ExpandedEventType;
export enum TimelineTabs {
query = 'query',
graph = 'graph',
@ -33,9 +18,3 @@ export enum TimelineTabs {
eql = 'eql',
session = 'session',
}
export type ExpandedDetailTimeline = {
[tab in TimelineTabs]?: ExpandedDetailType;
};
export type ExpandedDetail = Partial<Record<string, ExpandedDetailType>>;

View file

@ -24,7 +24,6 @@ export const mockGlobalState = {
defaultColumns: defaultHeaders,
dataViewId: 'security-solution-default',
deletedEventIds: [],
expandedDetail: {},
filters: [],
indexNames: ['.alerts-security.alerts-default'],
isSelectAllChecked: false,

View file

@ -7,7 +7,6 @@
import actionCreatorFactory from 'typescript-fsa';
import { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import type { ExpandedDetailType } from '../../common/types/detail_panel';
import type {
ColumnHeaderOptions,
SessionViewConfig,
@ -43,13 +42,6 @@ export const updateColumnWidth = actionCreator<{
width: number;
}>('UPDATE_COLUMN_WIDTH');
export type TableToggleDetailPanel = ExpandedDetailType & {
tabType?: string;
id: string;
};
export const toggleDetailPanel = actionCreator<TableToggleDetailPanel>('TOGGLE_DETAIL_PANEL');
export const removeColumn = actionCreator<{
id: string;
columnId: string;

View file

@ -64,7 +64,6 @@ export const tableDefaults: SubsetDataTableModel = {
defaultColumns: defaultHeaders,
dataViewId: null,
deletedEventIds: [],
expandedDetail: {},
filters: [],
indexNames: [],
isSelectAllChecked: false,

View file

@ -9,16 +9,13 @@ import { omit, union } from 'lodash/fp';
import { isEmpty } from 'lodash';
import type { EuiDataGridColumn } from '@elastic/eui';
import type { ExpandedDetail, ExpandedDetailType } from '../../common/types/detail_panel';
import type { ColumnHeaderOptions, SessionViewConfig, SortColumnTable } from '../../common/types';
import type { TableToggleDetailPanel } from './actions';
import type { DataTablePersistInput, TableById } from './types';
import type { DataTableModelSettings } from './model';
import { getDataTableManageDefaults, tableDefaults } from './defaults';
import { DEFAULT_TABLE_COLUMN_MIN_WIDTH } from '../../components/data_table/constants';
export const isNotNull = <T>(value: T | null): value is T => value !== null;
export type Maybe<T> = T | null;
/** The minimum width of a resized column */
@ -438,22 +435,6 @@ export const setSelectedTableEvents = ({
};
};
export const updateTableDetailsPanel = (action: TableToggleDetailPanel): ExpandedDetail => {
const { tabType, id, ...expandedDetails } = action;
const panelViewOptions = new Set(['eventDetail', 'hostDetail', 'networkDetail', 'userDetail']);
const expandedTabType = tabType ?? 'query';
const newExpandDetails = {
params: expandedDetails.params ? { ...expandedDetails.params } : {},
panelView: expandedDetails.panelView,
} as ExpandedDetailType;
return {
[expandedTabType]: panelViewOptions.has(expandedDetails.panelView ?? '')
? newExpandDetails
: {},
};
};
export const updateTableGraphEventId = ({
id,
graphEventId,

View file

@ -8,7 +8,6 @@
import type { EuiDataGridColumn } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import { ExpandedDetail } from '../../common/types/detail_panel';
import type {
ColumnHeaderOptions,
SessionViewConfig,
@ -44,8 +43,6 @@ export interface DataTableModel extends DataTableModelSettings {
dataViewId: string | null; // null if legacy pre-8.0 data table
/** Events to not be rendered **/
deletedEventIds: string[];
/** This holds the view information for the flyout when viewing data in a consuming view (i.e. hosts page) or the side panel in the primary data view */
expandedDetail: ExpandedDetail;
filters?: Filter[];
/** When non-empty, display a graph view for this event */
graphEventId?: string;
@ -82,7 +79,6 @@ export type SubsetDataTableModel = Readonly<
| 'defaultColumns'
| 'dataViewId'
| 'deletedEventIds'
| 'expandedDetail'
| 'filters'
| 'indexNames'
| 'isLoading'

View file

@ -19,7 +19,6 @@ import {
setEventsLoading,
setDataTableSelectAll,
setSelected,
toggleDetailPanel,
updateColumnOrder,
updateColumns,
updateColumnWidth,
@ -52,7 +51,6 @@ import {
updateTablePerPageOptions,
updateTableSort,
upsertTableColumn,
updateTableDetailsPanel,
updateTableGraphEventId,
updateTableSessionViewConfig,
} from './helpers';
@ -87,21 +85,6 @@ export const dataTableReducer = reducerWithInitialState(initialDataTableState)
dataTableSettingsProps,
}),
}))
.case(toggleDetailPanel, (state, action) => {
return {
...state,
tableById: {
...state.tableById,
[action.id]: {
...state.tableById[action.id],
expandedDetail: {
...state.tableById[action.id]?.expandedDetail,
...updateTableDetailsPanel(action),
},
},
},
};
})
.case(applyDeltaToColumnWidth, (state, { id, columnId, delta }) => ({
...state,
tableById: applyDeltaToTableColumnWidth({

View file

@ -227,7 +227,6 @@ Arguments:
| timelineIntegration?.editor_plugins.uiPlugin? | `EuiMarkdownEditorUiPlugin` |
| timelineIntegration?.hooks.useInsertTimeline | `(value: string, onChange: (newValue: string) => void): UseInsertTimelineReturn` |
| timelineIntegration?.ui?.renderInvestigateInTimelineActionComponent? | `(alertIds: string[]) => JSX.Element;` space to render `InvestigateInTimelineActionComponent` |
| timelineIntegration?.ui?renderTimelineDetailsPanel? | `() => JSX.Element;` space to render `TimelineDetailsPanel` |
#### `getCases`
UI component:

View file

@ -23,9 +23,6 @@ export const timelineIntegrationMock = {
hooks: {
useInsertTimeline: jest.fn(),
},
ui: {
renderTimelineDetailsPanel: () => mockTimelineComponent('timeline-details-panel'),
},
};
export const useTimelineContextMock = useTimelineContext as jest.Mock;

View file

@ -13,7 +13,6 @@ import { useCasesContext } from '../cases_context/use_cases_context';
import { CaseActionBar } from '../case_action_bar';
import { HeaderPage } from '../header_page';
import { EditableTitle } from '../header_page/editable_title';
import { useTimelineContext } from '../timeline_context/use_timeline_context';
import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs';
import { CaseViewActivity } from './components/case_view_activity';
import { CaseViewAlerts } from './components/case_view_alerts';
@ -49,8 +48,6 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
const activeTabId = getActiveTabId(urlParams?.tabId);
const timelineUi = useTimelineContext()?.ui;
const { onUpdateField, isLoading, loadingKey } = useOnUpdateField({
caseData,
});
@ -126,7 +123,6 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
)}
{activeTabId === CASE_VIEW_PAGE_TABS.FILES && <CaseViewFiles caseData={caseData} />}
</EuiFlexGroup>
{timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null}
</>
);
}

View file

@ -42,9 +42,6 @@ export interface CasesTimelineIntegration {
onChange: (newValue: string) => void
) => UseInsertTimelineReturn;
};
ui?: {
renderTimelineDetailsPanel?: () => JSX.Element;
};
}
// This context is available to all children of the stateful_event component where the provider is currently set

View file

@ -1,29 +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 type { TimelineTabs } from '../timeline';
type EmptyObject = Record<string | number, never>;
export type ExpandedEventType =
| {
panelView?: 'eventDetail';
params?: {
eventId: string;
indexName: string;
refetch?: () => void;
};
}
| EmptyObject;
export type ExpandedDetailType = ExpandedEventType;
export type ExpandedDetailTimeline = {
[tab in TimelineTabs]?: ExpandedDetailType;
};
export type ExpandedDetail = Partial<Record<string, ExpandedDetailType>>;

View file

@ -8,7 +8,6 @@
import type { Status } from '../api/detection_engine';
export * from './timeline';
export * from './detail_panel';
export * from './header_actions';
export * from './session_view';
export * from './bulk_actions';

View file

@ -11,8 +11,6 @@ export * from './data_provider';
export * from './rows';
export * from './store';
import type { ExpandedDetailType } from '../detail_panel';
/**
* Used for scrolling top inside a tab. Especially when swiching tabs.
*/
@ -24,11 +22,6 @@ export interface ScrollToTopEvent {
timestamp: number;
}
export type ToggleDetailPanel = ExpandedDetailType & {
tabType?: TimelineTabs;
id: string;
};
export enum TimelineTabs {
query = 'query',
graph = 'graph',

View file

@ -9,7 +9,6 @@ import type { Filter } from '@kbn/es-query';
import type { RowRendererId, TimelineTypeLiteral } from '../../api/timeline/model/api';
import type { Direction } from '../../search_strategy';
import type { ExpandedDetailTimeline } from '../detail_panel';
import type { ColumnHeaderOptions, ColumnId } from '../header_actions';
import type { DataProvider } from './data_provider';
@ -43,7 +42,6 @@ export interface TimelinePersistInput {
};
defaultColumns?: ColumnHeaderOptions[];
excludedRowRendererIds?: RowRendererId[];
expandedDetail?: ExpandedDetailTimeline;
filters?: Filter[];
id: string;
indexNames: string[];

View file

@ -24,31 +24,15 @@ import { getRuleDetailsUrl, useFormatUrl } from '../../common/components/link_to
import { useKibana, useNavigation } from '../../common/lib/kibana';
import { APP_ID, CASES_PATH, SecurityPageName } from '../../../common/constants';
import { timelineActions } from '../../timelines/store';
import { useSourcererDataView } from '../../sourcerer/containers';
import { SourcererScopeName } from '../../sourcerer/store/model';
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
import { getEndpointDetailsPath } from '../../management/common/routing';
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { useInsertTimeline } from '../components/use_insert_timeline';
import * as timelineMarkdownPlugin from '../../common/components/markdown_editor/plugins/timeline';
import { DetailsPanel } from '../../timelines/components/side_panel';
import { useFetchAlertData } from './use_fetch_alert_data';
import { useUpsellingMessage } from '../../common/hooks/use_upselling';
import { useFetchNotes } from '../../notes/hooks/use_fetch_notes';
const TimelineDetailsPanel = () => {
const { browserFields, runtimeMappings } = useSourcererDataView(SourcererScopeName.detections);
return (
<DetailsPanel
browserFields={browserFields}
entityType="events"
isFlyoutView
runtimeMappings={runtimeMappings}
scopeId={TimelineId.casePage}
/>
);
};
const CaseContainerComponent: React.FC = () => {
const { cases, telemetry } = useKibana().services;
const { getAppUrl, navigateTo } = useNavigation();
@ -115,7 +99,6 @@ const CaseContainerComponent: React.FC = () => {
columns: [],
dataViewId: null,
indexNames: [],
expandedDetail: {},
show: false,
})
);
@ -175,9 +158,6 @@ const CaseContainerComponent: React.FC = () => {
hooks: {
useInsertTimeline,
},
ui: {
renderTimelineDetailsPanel: TimelineDetailsPanel,
},
},
useFetchAlertData,
onAlertsTableLoaded,

View file

@ -1,223 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JSON View rendering should match snapshot 1`] = `
<styled.div>
<EuiCodeBlock
data-test-subj="jsonView"
fontSize="m"
isCopyable={true}
language="json"
paddingSize="m"
>
{
"_index": ".ds-logs-endpoint.events.network-default-2021.09.28-000001",
"_id": "TUWyf3wBFCFU0qRJTauW",
"_score": 1,
"fields": {
"host.os.full.text": [
"Debian 10"
],
"event.category": [
"network"
],
"process.name.text": [
"filebeat"
],
"host.os.name.text": [
"Linux"
],
"host.os.full": [
"Debian 10"
],
"host.hostname": [
"test-linux-1"
],
"process.pid": [
22535
],
"host.mac": [
"42:01:0a:c8:00:32"
],
"elastic.agent.id": [
"abcdefg-f6d5-4ce6-915d-8f1f8f413624"
],
"host.os.version": [
"10"
],
"host.os.name": [
"Linux"
],
"source.ip": [
"127.0.0.1"
],
"destination.address": [
"127.0.0.1"
],
"host.name": [
"test-linux-1"
],
"event.agent_id_status": [
"verified"
],
"event.kind": [
"event"
],
"event.outcome": [
"unknown"
],
"group.name": [
"root"
],
"user.id": [
"0"
],
"host.os.type": [
"linux"
],
"process.Ext.ancestry": [
"MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=",
"MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA=="
],
"user.Ext.real.id": [
"0"
],
"data_stream.type": [
"logs"
],
"host.architecture": [
"x86_64"
],
"process.name": [
"filebeat"
],
"agent.id": [
"2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624"
],
"source.port": [
54146
],
"ecs.version": [
"1.11.0"
],
"event.created": [
"2021-10-14T16:45:58.031Z"
],
"agent.version": [
"8.0.0-SNAPSHOT"
],
"host.os.family": [
"debian"
],
"destination.port": [
9200
],
"group.id": [
"0"
],
"user.name": [
"root"
],
"source.address": [
"127.0.0.1"
],
"process.entity_id": [
"MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA="
],
"host.ip": [
"127.0.0.1",
"::1",
"10.1.2.3",
"2001:0DB8:AC10:FE01::"
],
"process.executable.caseless": [
"/opt/elastic/agent/data/elastic-agent-058c40/install/filebeat-8.0.0-snapshot-linux-x86_64/filebeat"
],
"event.sequence": [
44872
],
"agent.type": [
"endpoint"
],
"process.executable.text": [
"/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat"
],
"group.Ext.real.name": [
"root"
],
"event.module": [
"endpoint"
],
"host.os.kernel": [
"4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)"
],
"host.os.full.caseless": [
"debian 10"
],
"host.id": [
"76ea303129f249aa7382338e4263eac1"
],
"process.name.caseless": [
"filebeat"
],
"network.type": [
"ipv4"
],
"process.executable": [
"/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat"
],
"user.Ext.real.name": [
"root"
],
"data_stream.namespace": [
"default"
],
"message": [
"Endpoint network event"
],
"destination.ip": [
"127.0.0.1"
],
"network.transport": [
"tcp"
],
"host.os.Ext.variant": [
"Debian"
],
"group.Ext.real.id": [
"0"
],
"event.ingested": [
"2021-10-14T16:46:04.000Z"
],
"event.action": [
"connection_attempted"
],
"@timestamp": [
"2021-10-14T16:45:58.031Z"
],
"host.os.platform": [
"debian"
],
"data_stream.dataset": [
"endpoint.events.network"
],
"event.type": [
"start"
],
"event.id": [
"MKPXftjGeHiQzUNj++++nn6R"
],
"host.os.name.caseless": [
"linux"
],
"event.dataset": [
"endpoint.events.network"
],
"user.name.text": [
"root"
]
}
}
</EuiCodeBlock>
</styled.div>
`;

View file

@ -1,816 +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 React from 'react';
import { waitFor, render, act } from '@testing-library/react';
import { AlertSummaryView } from './alert_summary_view';
import { mockAlertDetailsData } from './__mocks__';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { TestProviders, TestProvidersComponent } from '../../mock';
import { TimelineId } from '../../../../common/types';
import { mockBrowserFields } from '../../containers/source/mock';
import * as i18n from './translations';
jest.mock('../../lib/kibana');
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback', () => {
return {
useRuleWithFallback: jest.fn(),
};
});
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback', () => {
return {
useRuleWithFallback: jest.fn(),
};
});
jest.mock('@kbn/cell-actions/src/hooks/use_load_actions', () => {
const actual = jest.requireActual('@kbn/cell-actions/src/hooks/use_load_actions');
return {
...actual,
useLoadActions: jest.fn().mockImplementation(() => ({
value: [],
error: undefined,
loading: false,
})),
};
});
jest.mock('../../hooks/use_get_field_spec');
const props = {
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
browserFields: mockBrowserFields,
eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31',
scopeId: 'alerts-page',
title: '',
goToTable: jest.fn(),
};
describe('AlertSummaryView', () => {
beforeEach(() => {
jest.clearAllMocks();
(useRuleWithFallback as jest.Mock).mockReturnValue({
rule: {
note: 'investigation guide',
},
});
});
test('render correct items', async () => {
await act(async () => {
const { getByTestId } = render(
<TestProviders>
<AlertSummaryView {...props} />
</TestProviders>
);
expect(getByTestId('summary-view')).toBeInTheDocument();
});
});
test('it renders the action cell by default', async () => {
await act(async () => {
const { getAllByTestId } = render(
<TestProviders>
<AlertSummaryView {...props} />
</TestProviders>
);
expect(getAllByTestId('inlineActions').length).toBeGreaterThan(0);
});
});
test('Renders the correct global fields', async () => {
await act(async () => {
const { getByText } = render(
<TestProviders>
<AlertSummaryView {...props} />
</TestProviders>
);
['host.name', 'user.name', i18n.RULE_TYPE, 'query', 'rule.name'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('it does NOT render the action cell for the active timeline', async () => {
await act(async () => {
const { queryAllByTestId } = render(
<TestProviders>
<AlertSummaryView {...props} scopeId={TimelineId.active} />
</TestProviders>
);
expect(queryAllByTestId('inlineActions').length).toEqual(0);
});
});
test('it does NOT render the action cell when readOnly is passed', async () => {
await act(async () => {
const { queryAllByTestId } = render(
<TestProviders>
<AlertSummaryView {...{ ...props, isReadOnly: true }} />
</TestProviders>
);
expect(queryAllByTestId('inlineActions').length).toEqual(0);
});
});
test("render no investigation guide if it doesn't exist", async () => {
(useRuleWithFallback as jest.Mock).mockReturnValue({
rule: {
note: null,
},
});
await act(async () => {
const { queryByTestId } = render(
<TestProviders>
<AlertSummaryView {...props} />
</TestProviders>
);
await waitFor(() => {
expect(queryByTestId('summary-view-guide')).not.toBeInTheDocument();
});
});
});
test('User specified investigation fields appear in summary rows', async () => {
const mockData = mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.category') {
return {
...item,
values: ['network'],
originalValue: ['network'],
};
}
return item;
});
const renderProps = {
...props,
investigationFields: ['custom.field'],
data: [
...mockData,
{ category: 'custom', field: 'custom.field', values: ['blob'], originalValue: 'blob' },
] as TimelineEventsDetailsItem[],
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
[
'custom.field',
'host.name',
'user.name',
'destination.address',
'source.address',
'source.port',
'process.name',
].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Network event renders the correct summary rows', async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.category') {
return {
...item,
values: ['network'],
originalValue: ['network'],
};
}
return item;
}) as TimelineEventsDetailsItem[],
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
[
'host.name',
'user.name',
'destination.address',
'source.address',
'source.port',
'process.name',
].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('DNS network event renders the correct summary rows', async () => {
const renderProps = {
...props,
data: [
...(mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.category') {
return {
...item,
values: ['network'],
originalValue: ['network'],
};
}
return item;
}) as TimelineEventsDetailsItem[]),
{
category: 'dns',
field: 'dns.question.name',
values: ['www.example.com'],
originalValue: ['www.example.com'],
} as TimelineEventsDetailsItem,
],
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['dns.question.name', 'process.name'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Memory event code renders additional summary rows', async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.code') {
return {
...item,
values: ['shellcode_thread'],
originalValue: ['shellcode_thread'],
};
}
return item;
}) as TimelineEventsDetailsItem[],
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['host.name', 'user.name', 'Target.process.executable'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Behavior event code renders additional summary rows', async () => {
const actualRuleDescription = 'The actual rule description';
const renderProps = {
...props,
data: [
...mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.code') {
return {
...item,
values: ['behavior'],
originalValue: ['behavior'],
};
}
if (item.category === 'event' && item.field === 'event.category') {
return {
...item,
values: ['malware', 'process', 'file'],
originalValue: ['malware', 'process', 'file'],
};
}
return item;
}),
{
category: 'rule',
field: 'rule.description',
values: [actualRuleDescription],
originalValue: [actualRuleDescription],
},
] as TimelineEventsDetailsItem[],
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['host.name', 'user.name', 'process.name', actualRuleDescription].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Malware event category shows file fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.category') {
return {
...item,
values: ['malware'],
originalValue: ['malware'],
};
}
return item;
}),
{ category: 'file', field: 'file.name', values: ['malware.exe'] },
{
category: 'file',
field: 'file.hash.sha256',
values: ['3287rhf3847gb38fb3o984g9384g7b3b847gb'],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['host.name', 'user.name', 'file.name', 'file.hash.sha256'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Ransomware event code shows correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.code') {
return {
...item,
values: ['ransomware'],
originalValue: ['ransomware'],
};
}
return item;
}),
{ category: 'Ransomware', field: 'Ransomware.feature', values: ['mbr'] },
{
category: 'process',
field: 'process.hash.sha256',
values: ['3287rhf3847gb38fb3o984g9384g7b3b847gb'],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['process.hash.sha256', 'Ransomware.feature'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Machine learning events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['machine_learning'],
originalValue: ['machine_learning'],
};
}
return item;
}),
{
category: 'kibana',
field: 'kibana.alert.rule.parameters.machine_learning_job_id',
values: ['i_am_the_ml_job_id'],
},
{ category: 'kibana', field: 'kibana.alert.rule.parameters.anomaly_threshold', values: [2] },
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['i_am_the_ml_job_id', 'kibana.alert.rule.parameters.anomaly_threshold'].forEach(
(fieldId) => {
expect(getByText(fieldId));
}
);
});
});
test('[legacy] Machine learning events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['machine_learning'],
originalValue: ['machine_learning'],
};
}
return item;
}),
{
category: 'signal',
field: 'signal.rule.machine_learning_job_id',
values: ['i_am_the_ml_job_id'],
},
{ category: 'signal', field: 'signal.rule.anomaly_threshold', values: [2] },
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['i_am_the_ml_job_id', 'signal.rule.anomaly_threshold'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Threat match events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['threat_match'],
originalValue: ['threat_match'],
};
}
return item;
}),
{
category: 'kibana',
field: 'kibana.alert.rule.parameters.threat_index',
values: ['threat_index*'],
},
{
category: 'kibana',
field: 'kibana.alert.rule.parameters.threat_query',
values: ['*query*'],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['threat_index*', '*query*'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('[legacy] Threat match events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['threat_match'],
originalValue: ['threat_match'],
};
}
return item;
}),
{
category: 'signal',
field: 'signal.rule.threat_index',
values: ['threat_index*'],
},
{
category: 'signal',
field: 'signal.rule.threat_query',
values: ['*query*'],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['threat_index*', '*query*'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Ransomware event code resolves fields from the source event', async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.code') {
return {
...item,
values: ['ransomware'],
originalValue: ['ransomware'],
};
}
if (item.category === 'event' && item.field === 'event.category') {
return {
...item,
values: ['malware', 'process', 'file'],
originalValue: ['malware', 'process', 'file'],
};
}
return item;
}) as TimelineEventsDetailsItem[],
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['host.name', 'user.name', 'process.name'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Threshold events have special fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['threshold'],
originalValue: ['threshold'],
};
}
return item;
}),
{
category: 'kibana',
field: 'kibana.alert.threshold_result.count',
values: [9001],
originalValue: [9001],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.terms.value',
values: ['host-23084y2', '3084hf3n84p8934r8h'],
originalValue: ['host-23084y2', '3084hf3n84p8934r8h'],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.terms.field',
values: ['host.name', 'host.id'],
originalValue: ['host.name', 'host.id'],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.cardinality.field',
values: ['host.name'],
originalValue: ['host.name'],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.cardinality.value',
values: [9001],
originalValue: [9001],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['Event Count', 'Event Cardinality', 'host.name', 'host.id'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Threshold fields are not shown when data is malformated', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['threshold'],
originalValue: ['threshold'],
};
}
return item;
}),
{
category: 'kibana',
field: 'kibana.alert.threshold_result.count',
values: [9001],
originalValue: [9001],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.terms.field',
// This would be expected to have two entries
values: ['host.id'],
originalValue: ['host.id'],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.terms.value',
values: ['host-23084y2', '3084hf3n84p8934r8h'],
originalValue: ['host-23084y2', '3084hf3n84p8934r8h'],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.cardinality.field',
values: ['host.name'],
originalValue: ['host.name'],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.cardinality.value',
// This would be expected to have one entry
values: [],
originalValue: [],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['Event Count'].forEach((fieldId) => {
expect(getByText(fieldId));
});
[
'host.name [threshold]',
'host.id [threshold]',
'Event Cardinality',
'count(host.name) >= 9001',
].forEach((fieldText) => {
expect(() => getByText(fieldText)).toThrow();
});
});
});
test('Threshold fields are not shown when data is partially missing', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['threshold'],
originalValue: ['threshold'],
};
}
return item;
}),
{
category: 'kibana',
field: 'kibana.alert.threshold_result.terms.field',
// This would be expected to have two entries
values: ['host.id'],
originalValue: ['host.id'],
},
{
category: 'kibana',
field: 'kibana.alert.threshold_result.cardinality.field',
values: ['host.name'],
originalValue: ['host.name'],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
// The `value` fields are missing here, so the enriched field info cannot be calculated correctly
['host.id [threshold]', 'Threshold Cardinality', 'count(host.name) >= 9001'].forEach(
(fieldText) => {
expect(() => getByText(fieldText)).toThrow();
}
);
});
});
test('New terms events have special fields', () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
return {
...item,
values: ['new_terms'],
originalValue: ['new_terms'],
};
}
return item;
}),
{
category: 'kibana',
field: 'kibana.alert.new_terms',
values: ['127.0.0.1'],
originalValue: ['127.0.0.1'],
},
{
category: 'kibana',
field: 'kibana.alert.rule.parameters.new_terms_fields',
values: ['host.ip'],
originalValue: ['host.ip'],
},
] as TimelineEventsDetailsItem[];
const renderProps = {
...props,
data: enhancedData,
};
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
['New Terms', '127.0.0.1', 'New Terms fields', 'host.ip'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
test("doesn't render empty fields", async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.name') {
return {
category: 'kibana',
field: 'kibana.alert.rule.name',
values: undefined,
originalValue: undefined,
};
}
return item;
}) as TimelineEventsDetailsItem[],
};
await act(async () => {
const { queryByTestId } = render(
<TestProviders>
<AlertSummaryView {...renderProps} />
</TestProviders>
);
expect(queryByTestId('event-field-kibana.alert.rule.name')).not.toBeInTheDocument();
});
});
});

View file

@ -1,51 +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 React from 'react';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import { useSummaryRows } from './get_alert_summary_rows';
import { SummaryView } from './summary_view';
const AlertSummaryViewComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
eventId: string;
isDraggable?: boolean;
scopeId: string;
title: string;
goToTable: () => void;
isReadOnly?: boolean;
investigationFields?: string[];
}> = ({
browserFields,
data,
eventId,
isDraggable,
scopeId,
title,
goToTable,
isReadOnly,
investigationFields,
}) => {
const summaryRows = useSummaryRows({
browserFields,
data,
eventId,
isDraggable,
scopeId,
isReadOnly,
investigationFields,
});
return (
<SummaryView goToTable={goToTable} isReadOnly={isReadOnly} rows={summaryRows} title={title} />
);
};
export const AlertSummaryView = React.memo(AlertSummaryViewComponent);

View file

@ -1,222 +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 styled from 'styled-components';
import { get } from 'lodash/fp';
import React, { useMemo } from 'react';
import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { partition } from 'lodash';
import {
SecurityCellActions,
CellActionsMode,
SecurityCellActionsTrigger,
} from '../../cell_actions';
import * as i18n from './translations';
import type { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment } from './helpers';
import type { FieldsData } from '../types';
import type {
BrowserFields,
TimelineEventsDetailsItem,
} from '../../../../../common/search_strategy';
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view';
import { getSourcererScopeId } from '../../../../helpers';
import { getFieldFormat } from '../get_field_format';
export interface ThreatSummaryDescription {
data: FieldsData | undefined;
eventId: string;
index: number;
feedName: string | undefined;
scopeId: string;
value: string | undefined;
isDraggable?: boolean;
isReadOnly?: boolean;
}
const EnrichmentFieldFeedName = styled.span`
white-space: nowrap;
font-style: italic;
`;
export const StyledEuiFlexGroup = styled(EuiFlexGroup)`
.inlineActions {
opacity: 0;
}
.inlineActions-popoverOpen {
opacity: 1;
}
&:hover {
.inlineActions {
opacity: 1;
}
}
`;
const EnrichmentDescription: React.FC<ThreatSummaryDescription> = ({
data,
eventId,
index,
feedName,
scopeId,
value,
isDraggable,
isReadOnly,
}) => {
const metadata = useMemo(() => ({ scopeId }), [scopeId]);
if (!data || !value) return null;
const key = `alert-details-value-formatted-field-value-${scopeId}-${eventId}-${data.field}-${value}-${index}-${feedName}`;
return (
<StyledEuiFlexGroup key={key} direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<div>
<FormattedFieldValue
contextId={scopeId}
eventId={key}
fieldFormat={data.format}
fieldName={data.field}
fieldType={data.type}
isDraggable={isDraggable}
isObjectArray={data.isObjectArray}
value={value}
truncate={false}
/>
{feedName && (
<EnrichmentFieldFeedName>
{' '}
{i18n.FEED_NAME_PREPOSITION} {feedName}
</EnrichmentFieldFeedName>
)}
</div>
</EuiFlexItem>
<EuiFlexItem>
{value && !isReadOnly && (
<SecurityCellActions
data={{
field: data.field,
value,
}}
triggerId={SecurityCellActionsTrigger.DETAILS_FLYOUT}
mode={CellActionsMode.INLINE}
sourcererScopeId={getSourcererScopeId(scopeId)}
metadata={metadata}
visibleCellActions={3}
/>
)}
</EuiFlexItem>
</StyledEuiFlexGroup>
);
};
const EnrichmentSummaryComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
enrichments: CtiEnrichment[];
scopeId: string;
eventId: string;
isDraggable?: boolean;
isReadOnly?: boolean;
}> = ({ browserFields, data, enrichments, scopeId, eventId, isDraggable, isReadOnly }) => {
const parsedEnrichments = enrichments.map((enrichment, index) => {
const { field, type, feedName, value } = getEnrichmentIdentifiers(enrichment);
const eventData = data.find((item) => item.field === field);
const category = eventData?.category ?? '';
const browserField = get([category, 'fields', field ?? ''], browserFields);
const fieldsData: FieldsData = {
field: field ?? '',
format: getFieldFormat(browserField) ?? '',
type: browserField?.type ?? '',
isObjectArray: eventData?.isObjectArray ?? false,
};
return {
fieldsData,
type,
feedName,
index,
field,
browserField,
value,
};
});
const [investigation, indicator] = partition(parsedEnrichments, ({ type }) =>
isInvestigationTimeEnrichment(type)
);
return (
<>
{indicator.length > 0 && (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder paddingSize="s" grow={false}>
<ThreatSummaryPanelHeader
title={i18n.INDICATOR_ENRICHMENT_TITLE}
toolTipContent={i18n.INDICATOR_TOOLTIP_CONTENT}
/>
{indicator.map(({ fieldsData, index, field, feedName, browserField, value }) => (
<EnrichedDataRow
key={field}
field={field}
value={
<EnrichmentDescription
eventId={eventId}
index={index}
feedName={feedName}
scopeId={scopeId}
value={value}
data={fieldsData}
isDraggable={isDraggable}
isReadOnly={isReadOnly}
/>
}
/>
))}
</EuiPanel>
</EuiFlexItem>
)}
{investigation.length > 0 && (
<EuiFlexItem grow={false}>
<EuiPanel hasBorder paddingSize="s" grow={false}>
<ThreatSummaryPanelHeader
title={i18n.INVESTIGATION_ENRICHMENT_TITLE}
toolTipContent={i18n.INVESTIGATION_TOOLTIP_CONTENT}
/>
{investigation.map(({ fieldsData, index, field, feedName, browserField, value }) => (
<EnrichedDataRow
key={field}
field={field}
value={
<EnrichmentDescription
eventId={eventId}
index={index}
feedName={feedName}
scopeId={scopeId}
value={value}
data={fieldsData}
isDraggable={isDraggable}
isReadOnly={isReadOnly}
/>
}
/>
))}
</EuiPanel>
</EuiFlexItem>
)}
</>
);
};
export const EnrichmentSummary = React.memo(EnrichmentSummaryComponent);

View file

@ -72,6 +72,7 @@ const EnrichmentSection: React.FC<{
);
};
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
const ThreatDetailsViewComponent: React.FC<{
enrichments: CtiEnrichment[];
showInvestigationTimeEnrichments: boolean;

View file

@ -1,73 +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 React from 'react';
import { ThreatSummaryView } from './threat_summary_view';
import { TestProviders } from '../../../mock';
import { render } from '@testing-library/react';
import { buildEventEnrichmentMock } from '../../../../../common/search_strategy/security_solution/cti/index.mock';
import { mockAlertDetailsData } from '../__mocks__';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { mockBrowserFields } from '../../../containers/source/mock';
import { mockTimelines } from '../../../mock/mock_timelines_plugin';
jest.mock('../../../lib/kibana', () => ({
useKibana: () => ({
services: {
timelines: { ...mockTimelines },
},
}),
}));
jest.mock('../../../../helper_hooks', () => ({
useHasSecurityCapability: () => true,
}));
jest.mock('../table/field_name_cell');
const RISK_SCORE_DATA_ROWS = 2;
const EMPTY_RISK_SCORE = {
loading: false,
isModuleEnabled: true,
result: [],
};
describe('ThreatSummaryView', () => {
const eventId = '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31';
const scopeId = 'alerts-page';
const data = mockAlertDetailsData as TimelineEventsDetailsItem[];
const browserFields = mockBrowserFields;
it("renders 'Enriched with Threat Intelligence' panel with fields", () => {
const enrichments = [
buildEventEnrichmentMock({ 'matched.id': ['test.id'], 'matched.field': ['test.field'] }),
buildEventEnrichmentMock({ 'matched.id': ['other.id'], 'matched.field': ['other.field'] }),
];
const { getByText, getAllByTestId } = render(
<TestProviders>
<ThreatSummaryView
data={data}
browserFields={browserFields}
enrichments={enrichments}
eventId={eventId}
scopeId={scopeId}
hostRisk={EMPTY_RISK_SCORE}
userRisk={EMPTY_RISK_SCORE}
/>
</TestProviders>
);
expect(getByText('Enriched with threat intelligence')).toBeInTheDocument();
expect(getAllByTestId('EnrichedDataRow')).toHaveLength(
enrichments.length + RISK_SCORE_DATA_ROWS
);
});
});

View file

@ -1,171 +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 styled from 'styled-components';
import type { FC, PropsWithChildren } from 'react';
import React from 'react';
import { EuiTitle, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import type { HostRisk, UserRisk } from '../../../../entity_analytics/api/types';
import * as i18n from './translations';
import type { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
import type {
BrowserFields,
TimelineEventsDetailsItem,
RiskSeverity,
} from '../../../../../common/search_strategy';
import { RiskSummaryPanel } from '../../../../entity_analytics/components/risk_summary_panel';
import { EnrichmentSummary } from './enrichment_summary';
import { RiskScoreEntity } from '../../../../../common/search_strategy';
import { useHasSecurityCapability } from '../../../../helper_hooks';
import { RiskScoreInfoTooltip } from '../../../../overview/components/common';
const UppercaseEuiTitle = styled(EuiTitle)`
text-transform: uppercase;
`;
const ThreatSummaryPanelTitle: FC<PropsWithChildren<unknown>> = ({ children }) => (
<UppercaseEuiTitle size="xxxs">
<h5>{children}</h5>
</UppercaseEuiTitle>
);
const StyledEnrichmentFieldTitle = styled(EuiTitle)`
width: 220px;
`;
const EnrichmentFieldTitle: React.FC<{
title: string | React.ReactNode | undefined;
}> = ({ title }) => (
<StyledEnrichmentFieldTitle size="xxxs">
<h6>{title}</h6>
</StyledEnrichmentFieldTitle>
);
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
margin-top: ${({ theme }) => theme.eui.euiSizeS};
`;
export const EnrichedDataRow: React.FC<{
field: string | React.ReactNode | undefined;
value: React.ReactNode;
}> = ({ field, value }) => (
<StyledEuiFlexGroup
direction="row"
gutterSize="none"
responsive
alignItems="center"
data-test-subj="EnrichedDataRow"
>
<EuiFlexItem style={{ flexShrink: 0 }} grow={false}>
<EnrichmentFieldTitle title={field} />
</EuiFlexItem>
<EuiFlexItem className="eui-textBreakWord">{value}</EuiFlexItem>
</StyledEuiFlexGroup>
);
export const ThreatSummaryPanelHeader: React.FC<{
title: string | React.ReactNode;
toolTipContent: React.ReactNode;
toolTipTitle?: React.ReactNode;
}> = ({ title, toolTipContent, toolTipTitle }) => {
return (
<EuiFlexGroup direction="row" gutterSize="none" alignItems="center">
<EuiFlexItem>
<ThreatSummaryPanelTitle>{title}</ThreatSummaryPanelTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RiskScoreInfoTooltip
toolTipContent={toolTipContent}
toolTipTitle={toolTipTitle ?? title}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const ThreatSummaryViewComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
enrichments: CtiEnrichment[];
eventId: string;
scopeId: string;
hostRisk: HostRisk;
userRisk: UserRisk;
isDraggable?: boolean;
isReadOnly?: boolean;
}> = ({
browserFields,
data,
enrichments,
eventId,
scopeId,
hostRisk,
userRisk,
isDraggable,
isReadOnly,
}) => {
const originalHostRisk = data?.find(
(eventDetail) => eventDetail?.field === 'host.risk.calculated_level'
)?.values?.[0] as RiskSeverity | undefined;
const originalUserRisk = data?.find(
(eventDetail) => eventDetail?.field === 'user.risk.calculated_level'
)?.values?.[0] as RiskSeverity | undefined;
const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics');
if (!hasEntityAnalyticsCapability && enrichments.length === 0) {
return null;
}
return (
<>
<EuiHorizontalRule />
<EuiTitle size="xxxs">
<h5>{i18n.ENRICHED_DATA}</h5>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFlexGroup direction="column" gutterSize="m" style={{ flexGrow: 0 }}>
{hasEntityAnalyticsCapability && (
<>
<EuiFlexItem grow={false}>
<RiskSummaryPanel
riskEntity={RiskScoreEntity.host}
risk={hostRisk}
originalRisk={originalHostRisk}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RiskSummaryPanel
riskEntity={RiskScoreEntity.user}
risk={userRisk}
originalRisk={originalUserRisk}
/>
</EuiFlexItem>
</>
)}
<EnrichmentSummary
browserFields={browserFields}
data={data}
enrichments={enrichments}
scopeId={scopeId}
eventId={eventId}
isDraggable={isDraggable}
isReadOnly={isReadOnly}
/>
</EuiFlexGroup>
</>
);
};
export const ThreatSummaryView = React.memo(ThreatSummaryViewComponent);

View file

@ -6,8 +6,6 @@
*/
import { i18n } from '@kbn/i18n';
import { getRiskEntityTranslation } from '../../../../entity_analytics/components/risk_score/translations';
import type { RiskScoreEntity } from '../../../../../common/search_strategy';
export * from '../../../../entity_analytics/components/risk_score/translations';
export const FEED_NAME_PREPOSITION = i18n.translate(
@ -46,13 +44,6 @@ export const INVESTIGATION_TOOLTIP_CONTENT = i18n.translate(
}
);
export const NO_INVESTIGATION_ENRICHMENTS_DESCRIPTION = i18n.translate(
'xpack.securitySolution.alertDetails.noInvestigationEnrichmentsDescription',
{
defaultMessage: 'This alert does not have supplemental threat intelligence data.',
}
);
export const NO_ENRICHMENTS_FOUND_DESCRIPTION = i18n.translate(
'xpack.securitySolution.alertDetails.noEnrichmentsFoundDescription',
{
@ -85,13 +76,6 @@ export const REFRESH = i18n.translate('xpack.securitySolution.alertDetails.refre
defaultMessage: 'Refresh',
});
export const ENRICHED_DATA = i18n.translate(
'xpack.securitySolution.alertDetails.overview.enrichedDataTitle',
{
defaultMessage: 'Enriched data',
}
);
export const NESTED_OBJECT_VALUES_NOT_RENDERED = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentObjectValuesNotRendered',
{
@ -99,27 +83,3 @@ export const NESTED_OBJECT_VALUES_NOT_RENDERED = i18n.translate(
'This field contains nested object values, which are not rendered here. See the full document for all fields/values',
}
);
export const CURRENT_RISK_LEVEL = (riskEntity: RiskScoreEntity) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.hostRiskLevel', {
defaultMessage: 'Current {riskEntity} risk level',
values: {
riskEntity: getRiskEntityTranslation(riskEntity, true),
},
});
export const ORIGINAL_RISK_LEVEL = (riskEntity: RiskScoreEntity) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.originalHostRiskLevel', {
defaultMessage: 'Original {riskEntity} risk level',
values: {
riskEntity: getRiskEntityTranslation(riskEntity, true),
},
});
export const RISK_DATA_TITLE = (riskEntity: RiskScoreEntity) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.hostRiskDataTitle', {
defaultMessage: '{riskEntity} Risk Data',
values: {
riskEntity: getRiskEntityTranslation(riskEntity),
},
});

View file

@ -1,278 +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 { waitFor } from '@testing-library/react';
import { mount } from 'enzyme';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
import '../../mock/react_beautiful_dnd';
import {
mockDetailItemData,
mockDetailItemDataId,
mockEcsDataWithAlert,
rawEventData,
TestProviders,
} from '../../mock';
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 { useKibana } from '../../lib/kibana';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
jest.mock('../../hooks/use_experimental_features');
jest.mock('../../../timelines/components/timeline/body/renderers', () => {
return {
defaultRowRenderers: [
{
id: 'test',
isInstance: () => true,
renderRow: jest.fn(),
},
],
};
});
jest.mock('../../lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
jest.mock('../../containers/cti/event_enrichment');
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback', () => {
return {
useRuleWithFallback: jest.fn().mockReturnValue({
rule: {
note: 'investigation guide',
},
}),
};
});
jest.mock('../guided_onboarding_tour/tour_step', () => ({
GuidedOnboardingTourStep: jest.fn(({ children }) => (
<div data-test-subj="guided-onboarding">{children}</div>
)),
}));
jest.mock('../link_to');
describe('EventDetails', () => {
const defaultProps = {
browserFields: mockBrowserFields,
data: mockDetailItemData,
detailsEcsData: mockEcsDataWithAlert,
id: mockDetailItemDataId,
isAlert: false,
onEventViewSelected: jest.fn(),
onThreatViewSelected: jest.fn(),
timelineTabType: TimelineTabs.query,
scopeId: 'table-test',
eventView: EventsViewType.summaryView,
hostRisk: { fields: [], loading: true },
indexName: 'test',
handleOnEventClosed: jest.fn(),
rawEventData,
};
const alertsProps = {
...defaultProps,
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
isAlert: true,
};
let wrapper: ReactWrapper;
let alertsWrapper: ReactWrapper;
beforeAll(async () => {
(useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({
result: [],
range: { to: 'now', from: 'now-30d' },
setRange: jest.fn(),
loading: false,
});
wrapper = mount(
<TestProviders>
<EventDetails {...defaultProps} />
</TestProviders>
) as ReactWrapper;
alertsWrapper = mount(
<TestProviders>
<EventDetails {...alertsProps} />
</TestProviders>
) as ReactWrapper;
await waitFor(() => wrapper.update());
});
describe('tabs', () => {
['Table', 'JSON'].forEach((tab) => {
test(`it renders the ${tab} tab`, () => {
expect(
wrapper
.find('[data-test-subj="eventDetails"]')
.find('[role="tablist"]')
.containsMatchingElement(<span>{tab}</span>)
).toBeTruthy();
});
});
test('the Table tab is selected by default', () => {
expect(
wrapper.find('[data-test-subj="eventDetails"]').find('.euiTab-isSelected').first().text()
).toEqual('Table');
});
});
describe('alerts tabs', () => {
['Overview', 'Threat Intel', 'Table', 'JSON'].forEach((tab) => {
test(`it renders the ${tab} tab`, () => {
expect(
alertsWrapper
.find('[data-test-subj="eventDetails"]')
.find('[role="tablist"]')
.containsMatchingElement(<span>{tab}</span>)
).toBeTruthy();
});
});
test('the Overview tab is selected by default', () => {
expect(
alertsWrapper
.find('[data-test-subj="eventDetails"]')
.find('.euiTab-isSelected')
.first()
.text()
).toEqual('Overview');
});
test('Enrichment count is displayed as a notification', () => {
expect(
alertsWrapper.find('[data-test-subj="enrichment-count-notification"]').hostNodes().text()
).toEqual('1');
});
});
describe('summary view tab', () => {
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
);
});
test('renders GuidedOnboardingTourStep', () => {
expect(alertsWrapper.find('[data-test-subj="guided-onboarding"]').exists()).toEqual(true);
});
});
describe('threat intel tab', () => {
it('renders a "no enrichments" panel view if there are no enrichments', () => {
alertsWrapper.find('[data-test-subj="threatIntelTab"]').first().simulate('click');
expect(alertsWrapper.find('[data-test-subj="no-enrichments-found"]').exists()).toEqual(true);
});
it('does not render if readOnly prop is passed', async () => {
const newProps = { ...defaultProps, isReadOnly: true };
wrapper = mount(
<TestProviders>
<EventDetails {...newProps} />
</TestProviders>
) as ReactWrapper;
alertsWrapper = mount(
<TestProviders>
<EventDetails {...{ ...alertsProps, ...newProps }} />
</TestProviders>
) as ReactWrapper;
await waitFor(() => wrapper.update());
expect(alertsWrapper.find('[data-test-subj="threatIntelTab"]').exists()).toBeFalsy();
});
});
describe('osquery tab', () => {
let featureFlags: { endpointResponseActionsEnabled: boolean; responseActionsEnabled: boolean };
beforeEach(() => {
featureFlags = { endpointResponseActionsEnabled: false, responseActionsEnabled: true };
const useIsExperimentalFeatureEnabledMock = (feature: keyof typeof featureFlags) =>
featureFlags[feature];
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(
useIsExperimentalFeatureEnabledMock
);
});
it('should not be rendered if not provided with specific raw data', () => {
expect(alertsWrapper.find('[data-test-subj="osqueryViewTab"]').exists()).toEqual(false);
});
it('render osquery tab', async () => {
const {
services: { osquery },
} = useKibanaMock();
if (osquery) {
jest.spyOn(osquery, 'fetchAllLiveQueries').mockReturnValue({
data: {
// @ts-expect-error - we don't need all the response details to test the functionality
data: {
items: [
{
_id: 'testId',
_index: 'testIndex',
fields: {
action_id: ['testActionId'],
'queries.action_id': ['testQueryActionId'],
'queries.query': ['select * from users'],
'@timestamp': ['2022-09-08T18:16:30.256Z'],
},
},
],
},
},
});
}
const newProps = {
...defaultProps,
rawEventData: {
...rawEventData,
fields: {
...rawEventData.fields,
'agent.id': ['testAgent'],
'kibana.alert.rule.name': ['test-rule'],
'kibana.alert.rule.parameters': [
{
response_actions: [{ action_type_id: '.osquery' }],
},
],
},
},
};
wrapper = mount(
<TestProviders>
<EventDetails {...newProps} />
</TestProviders>
) as ReactWrapper;
alertsWrapper = mount(
<TestProviders>
<EventDetails {...{ ...alertsProps, ...newProps }} />
</TestProviders>
) as ReactWrapper;
await waitFor(() => wrapper.update());
expect(alertsWrapper.find('[data-test-subj="osqueryViewTab"]').exists()).toEqual(true);
});
});
});

View file

@ -5,490 +5,7 @@
* 2.0.
*/
import type { EuiTabbedContentTab } from '@elastic/eui';
import {
EuiFlexGroup,
EuiHorizontalRule,
EuiSkeletonText,
EuiLoadingSpinner,
EuiNotificationBadge,
EuiSpacer,
EuiTabbedContent,
EuiTitle,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import type { RawEventData } from '../../../../common/types/response_actions';
import { useResponseActionsView } from './response_actions_view';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import type { SearchHit } from '../../../../common/search_strategy';
import { getMitreComponentParts } from '../../../detections/mitre/get_mitre_threat_component';
import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step';
import { isDetectionsAlertsTable } from '../top_n/helpers';
import {
AlertsCasesTourSteps,
getTourAnchor,
SecurityStepId,
} from '../guided_onboarding_tour/tour_config';
import { EventFieldsBrowser } from './event_fields_browser';
import { JsonView } from './json_view';
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 { BrowserFields } from '../../containers/source';
import { useInvestigationTimeEnrichment } from '../../containers/cti/event_enrichment';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
import type { TimelineTabs } from '../../../../common/types/timeline';
import {
filterDuplicateEnrichments,
getEnrichmentFields,
parseExistingEnrichments,
timelineDataToEnrichment,
} from './cti_details/helpers';
import { EnrichmentRangePicker } from './cti_details/enrichment_range_picker';
import { InvestigationGuideView } from './investigation_guide_view';
import { Overview } from './overview';
import { Insights } from './insights/insights';
import { useRiskScoreData } from '../../../entity_analytics/api/hooks/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';
import { useOsqueryTab } from './osquery_tab';
export const EVENT_DETAILS_CONTEXT_ID = 'event-details';
type EventViewTab = EuiTabbedContentTab;
export type EventViewId =
| EventsViewType.tableView
| EventsViewType.jsonView
| EventsViewType.summaryView
| EventsViewType.threatIntelView
// Depending on endpointResponseActionsEnabled flag whether to render Osquery Tab or the commonTab (osquery + endpoint results)
| EventsViewType.osqueryView
| EventsViewType.responseActionsView;
export enum EventsViewType {
tableView = 'table-view',
jsonView = 'json-view',
summaryView = 'summary-view',
threatIntelView = 'threat-intel-view',
osqueryView = 'osquery-results-view',
responseActionsView = 'response-actions-results-view',
}
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
detailsEcsData: Ecs | null;
id: string;
isAlert: boolean;
isDraggable?: boolean;
rawEventData: object | undefined;
timelineTabType: TimelineTabs | 'flyout';
scopeId: string;
handleOnEventClosed: () => void;
isReadOnly?: boolean;
}
const StyledEuiTabbedContent = styled(EuiTabbedContent)`
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
> [role='tabpanel'] {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
::-webkit-scrollbar {
-webkit-appearance: none;
width: 7px;
}
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5);
-webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}
}
`;
const TabContentWrapper = styled.div`
height: 100%;
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 ThreatTacticContainer = styled(EuiFlexGroup)`
flex-grow: 0;
flex-wrap: nowrap;
& .euiFlexGroup {
flex-wrap: nowrap;
}
`;
const ThreatTacticDescription = styled.div`
padding-left: ${(props) => props.theme.eui.euiSizeL};
`;
const EventDetailsComponent: React.FC<Props> = ({
browserFields,
data,
detailsEcsData,
id,
isAlert,
isDraggable,
rawEventData,
scopeId,
timelineTabType,
handleOnEventClosed,
isReadOnly,
}) => {
const [selectedTabId, setSelectedTabId] = useState<EventViewId>(EventsViewType.summaryView);
const handleTabClick = useCallback(
(tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EventViewId),
[]
);
const goToTableTab = useCallback(() => setSelectedTabId(EventsViewType.tableView), []);
const eventFields = useMemo(() => getEnrichmentFields(data), [data]);
const basicAlertData = useBasicDataFromDetailsData(data);
const { rule: maybeRule } = useRuleWithFallback(basicAlertData.ruleId);
const existingEnrichments = useMemo(
() =>
isAlert
? parseExistingEnrichments(data).map((enrichmentData) =>
timelineDataToEnrichment(enrichmentData)
)
: [],
[data, isAlert]
);
const {
result: enrichmentsResponse,
loading: isEnrichmentsLoading,
setRange,
range,
} = useInvestigationTimeEnrichment(eventFields);
const threatDetails = useMemo(
() => getMitreComponentParts(rawEventData as SearchHit),
[rawEventData]
);
const allEnrichments = useMemo(() => {
if (isEnrichmentsLoading || !enrichmentsResponse?.enrichments) {
return existingEnrichments;
}
return filterDuplicateEnrichments([...existingEnrichments, ...enrichmentsResponse.enrichments]);
}, [isEnrichmentsLoading, enrichmentsResponse, existingEnrichments]);
const enrichmentCount = allEnrichments.length;
const { hostRisk, userRisk, isAuthorized } = useRiskScoreData(data);
const renderer = useMemo(
() =>
detailsEcsData != null
? getRowRenderer({ data: detailsEcsData, rowRenderers: defaultRowRenderers })
: null,
[detailsEcsData]
);
const isTourAnchor = useMemo(() => isDetectionsAlertsTable(scopeId), [scopeId]);
const showThreatSummary = useMemo(() => {
const hasEnrichments = enrichmentCount > 0;
const hasRiskInfoWithLicense = isAuthorized && (hostRisk || userRisk);
return hasEnrichments || hasRiskInfoWithLicense;
}, [enrichmentCount, hostRisk, isAuthorized, userRisk]);
const endpointResponseActionsEnabled = useIsExperimentalFeatureEnabled(
'endpointResponseActionsEnabled'
);
const summaryTab: EventViewTab | undefined = useMemo(
() =>
isAlert
? {
id: EventsViewType.summaryView,
name: i18n.OVERVIEW,
'data-test-subj': 'overviewTab',
content: (
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
step={AlertsCasesTourSteps.reviewAlertDetailsFlyout}
tourId={SecurityStepId.alertsCases}
>
<>
<EuiSpacer size="m" />
<Overview
browserFields={browserFields}
contextId={scopeId}
data={data}
eventId={id}
scopeId={scopeId}
handleOnEventClosed={handleOnEventClosed}
isReadOnly={isReadOnly}
/>
<EuiSpacer size="l" />
{threatDetails && threatDetails[0] && (
<ThreatTacticContainer
alignItems="flexStart"
direction="column"
wrap={false}
gutterSize="none"
>
<>
<EuiTitle size="xxs">
<h5>{threatDetails[0].title}</h5>
</EuiTitle>
<ThreatTacticDescription>
{threatDetails[0].description}
</ThreatTacticDescription>
</>
</ThreatTacticContainer>
)}
<EuiSpacer size="l" />
{renderer != null && detailsEcsData != null && (
<div>
<EuiTitle size="xs">
<h5>{i18n.ALERT_REASON}</h5>
</EuiTitle>
<EuiSpacer size="s" />
<RendererContainer data-test-subj="renderer">
{renderer.renderRow({
contextId: EVENT_DETAILS_CONTEXT_ID,
data: detailsEcsData,
isDraggable: isDraggable ?? false,
scopeId,
})}
</RendererContainer>
</div>
)}
<EuiHorizontalRule />
<AlertSummaryView
{...{
data,
eventId: id,
browserFields,
isDraggable,
scopeId,
title: i18n.HIGHLIGHTED_FIELDS,
isReadOnly,
}}
goToTable={goToTableTab}
investigationFields={maybeRule?.investigation_fields?.field_names ?? []}
/>
<EuiSpacer size="xl" />
<Insights
browserFields={browserFields}
eventId={id}
data={data}
scopeId={scopeId}
isReadOnly={isReadOnly}
/>
{showThreatSummary && (
<ThreatSummaryView
isDraggable={isDraggable}
hostRisk={hostRisk}
userRisk={userRisk}
browserFields={browserFields}
data={data}
eventId={id}
scopeId={scopeId}
enrichments={allEnrichments}
isReadOnly={isReadOnly}
/>
)}
{isEnrichmentsLoading && (
<>
<EuiSkeletonText lines={2} />
</>
)}
{basicAlertData.ruleId && maybeRule?.note && (
<InvestigationGuideView basicData={basicAlertData} ruleNote={maybeRule.note} />
)}
</>
</GuidedOnboardingTourStep>
),
}
: undefined,
[
isAlert,
isTourAnchor,
browserFields,
scopeId,
data,
id,
handleOnEventClosed,
isReadOnly,
threatDetails,
renderer,
detailsEcsData,
isDraggable,
goToTableTab,
maybeRule?.investigation_fields?.field_names,
maybeRule?.note,
showThreatSummary,
hostRisk,
userRisk,
allEnrichments,
isEnrichmentsLoading,
basicAlertData,
]
);
const threatIntelTab = useMemo(
() =>
isAlert && !isReadOnly
? {
id: EventsViewType.threatIntelView,
'data-test-subj': 'threatIntelTab',
name: i18n.THREAT_INTEL,
append: (
<>
{isEnrichmentsLoading ? (
<EuiLoadingSpinner />
) : (
<EuiNotificationBadge data-test-subj="enrichment-count-notification">
{enrichmentCount}
</EuiNotificationBadge>
)}
</>
),
content: (
<ThreatDetailsView
before={<EuiSpacer size="m" />}
loading={isEnrichmentsLoading}
enrichments={allEnrichments}
showInvestigationTimeEnrichments={!isEmpty(eventFields)}
>
<>
<EnrichmentRangePicker
setRange={setRange}
loading={isEnrichmentsLoading}
range={range}
/>
<EuiSpacer size="m" />
</>
</ThreatDetailsView>
),
}
: undefined,
[
allEnrichments,
setRange,
range,
enrichmentCount,
isAlert,
eventFields,
isEnrichmentsLoading,
isReadOnly,
]
);
const tableTab = useMemo(
() => ({
id: EventsViewType.tableView,
'data-test-subj': 'tableTab',
name: i18n.TABLE,
content: (
<>
<EuiSpacer size="l" />
<EventFieldsBrowser
browserFields={browserFields}
data={data}
eventId={id}
isDraggable={isDraggable}
scopeId={scopeId}
timelineTabType={timelineTabType}
isReadOnly={isReadOnly}
/>
</>
),
}),
[browserFields, data, id, isDraggable, scopeId, timelineTabType, isReadOnly]
);
const jsonTab = useMemo(
() => ({
id: EventsViewType.jsonView,
'data-test-subj': 'jsonViewTab',
name: i18n.JSON_VIEW,
content: (
<>
<EuiSpacer size="m" />
<TabContentWrapper data-test-subj="jsonViewWrapper">
<JsonView rawEventData={rawEventData} />
</TabContentWrapper>
</>
),
}),
[rawEventData]
);
const responseActionsTab = useResponseActionsView({
rawEventData: rawEventData as RawEventData,
...(detailsEcsData !== null ? { ecsData: detailsEcsData } : {}),
});
const osqueryTab = useOsqueryTab({
rawEventData: rawEventData as RawEventData,
...(detailsEcsData !== null ? { ecsData: detailsEcsData } : {}),
});
const responseActionsTabs = useMemo(() => {
return endpointResponseActionsEnabled ? [responseActionsTab] : [osqueryTab];
}, [endpointResponseActionsEnabled, osqueryTab, responseActionsTab]);
const tabs = useMemo(() => {
return [summaryTab, threatIntelTab, tableTab, jsonTab, ...responseActionsTabs].filter(
(tab: EventViewTab | undefined): tab is EventViewTab => !!tab
);
}, [summaryTab, threatIntelTab, tableTab, jsonTab, responseActionsTabs]);
const selectedTab = useMemo(
() => tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0],
[tabs, selectedTabId]
);
const tourAnchor = useMemo(
() => (isTourAnchor ? { 'tour-step': getTourAnchor(3, SecurityStepId.alertsCases) } : {}),
[isTourAnchor]
);
return (
<>
<EuiSpacer size="s" />
<StyledEuiTabbedContent
{...tourAnchor}
data-test-subj="eventDetails"
tabs={tabs}
selectedTab={selectedTab}
onTabClick={handleTabClick}
key="event-summary-tabs"
/>
</>
);
};
EventDetailsComponent.displayName = 'EventDetailsComponent';
export const EventDetails = React.memo(EventDetailsComponent);

View file

@ -166,8 +166,8 @@ const useFieldBrowserPagination = () => {
* This callback, invoked via `EuiInMemoryTable`'s `rowProps, assigns
* attributes to every `<tr>`.
*/
/** Renders a table view or JSON view of the `ECS` `data` */
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const EventFieldsBrowser = React.memo<Props>(
({
browserFields,

View file

@ -1,21 +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 type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
type TimelineEventsDetailsItemWithValues = TimelineEventsDetailsItem & {
values: string[];
};
/**
* Checks if the `item` has a non-empty `values` array
*/
export function hasData(
item?: TimelineEventsDetailsItem
): item is TimelineEventsDetailsItemWithValues {
return Boolean(item && item.values && item.values.length);
}

View file

@ -1,58 +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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { InsightAccordion } from './insight_accordion';
const noopRenderer = () => null;
describe('InsightAccordion', () => {
it("shows a loading indicator when it's in the loading state", () => {
const loadingText = 'loading text';
render(
<TestProviders>
<InsightAccordion
state="loading"
text={loadingText}
prefix=""
renderContent={noopRenderer}
/>
</TestProviders>
);
expect(screen.getByText(loadingText)).toBeInTheDocument();
});
it("shows an error when it's in the error state", () => {
const errorText = 'error text';
render(
<TestProviders>
<InsightAccordion state="error" text={errorText} prefix="" renderContent={noopRenderer} />
</TestProviders>
);
expect(screen.getByText(errorText)).toBeInTheDocument();
});
it('shows the text and renders the correct content', () => {
const text = 'the text';
const contentText = 'content text';
const contentRenderer = () => <span>{contentText}</span>;
render(
<TestProviders>
<InsightAccordion state="success" text={text} prefix="" renderContent={contentRenderer} />
</TestProviders>
);
expect(screen.getByText(text)).toBeInTheDocument();
expect(screen.getByText(contentText)).toBeInTheDocument();
});
});

View file

@ -1,84 +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 type { ReactNode } from 'react';
import React from 'react';
import { noop } from 'lodash/fp';
import type { EuiAccordionProps } from '@elastic/eui';
import { EuiAccordion, EuiIcon, useGeneratedHtmlId } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
const StyledAccordion = euiStyled(EuiAccordion)`
border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
padding: 10px 8px;
border-radius: 6px;
`;
export type InsightAccordionState = 'loading' | 'error' | 'success';
interface Props {
prefix: string;
state: InsightAccordionState;
text: string;
renderContent: () => ReactNode;
extraAction?: EuiAccordionProps['extraAction'];
onToggle?: EuiAccordionProps['onToggle'];
forceState?: EuiAccordionProps['forceState'];
}
/**
* A special accordion that is used in the Insights section on the alert flyout.
* It wraps logic and custom styling around the loading, error and success states of an insight section.
*/
export const InsightAccordion = React.memo<Props>(
({ prefix, state, text, renderContent, onToggle = noop, extraAction, forceState }) => {
const accordionId = useGeneratedHtmlId({ prefix });
switch (state) {
case 'loading':
// Don't render content when loading
return (
<StyledAccordion id={accordionId} buttonContent={text} onToggle={onToggle} isLoading />
);
case 'error':
// Display an alert icon and don't render content when there was an error
return (
<StyledAccordion
id={accordionId}
buttonContent={
<span>
<EuiIcon type="warning" color="danger" style={{ marginRight: '6px' }} />
{text}
</span>
}
onToggle={onToggle}
extraAction={extraAction}
/>
);
case 'success':
// The accordion can display the content now
return (
<StyledAccordion
tour-step={`${prefix}-accordion`}
data-test-subj={`${prefix}-accordion`}
id={accordionId}
buttonContent={text}
onToggle={onToggle}
paddingSize="l"
extraAction={extraAction}
forceState={forceState}
>
{renderContent()}
</StyledAccordion>
);
default:
return null;
}
}
);
InsightAccordion.displayName = 'InsightAccordion';

View file

@ -1,177 +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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
import { licenseService } from '../../../hooks/use_license';
import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
import { Insights } from './insights';
import * as i18n from './translations';
const mockedUseKibana = mockUseKibana();
const mockCanUseCases = jest.fn();
jest.mock('../../../lib/kibana', () => {
const original = jest.requireActual('../../../lib/kibana');
return {
...original,
useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }),
useKibana: () => ({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
cases: {
api: {
getRelatedCases: jest.fn(),
},
helpers: { canUseCases: mockCanUseCases },
},
},
}),
};
});
jest.mock('../../../hooks/use_license', () => {
const licenseServiceInstance = {
isPlatinumPlus: jest.fn(),
isEnterprise: jest.fn(() => true),
};
return {
licenseService: licenseServiceInstance,
useLicense: () => {
return licenseServiceInstance;
},
};
});
const licenseServiceMock = licenseService as jest.Mocked<typeof licenseService>;
const dataWithoutAgentType: TimelineEventsDetailsItem[] = [
{
category: 'process',
field: 'process.entity_id',
isObjectArray: false,
values: ['32082y34028u34'],
},
{
category: 'kibana',
field: 'kibana.alert.ancestors.id',
isObjectArray: false,
values: ['woeurhw98rhwr'],
},
{
category: 'kibana',
field: 'kibana.alert.rule.parameters.index',
isObjectArray: false,
values: ['fakeindex'],
},
];
const data: TimelineEventsDetailsItem[] = [
...dataWithoutAgentType,
{
category: 'agent',
field: 'agent.type',
isObjectArray: false,
values: ['endpoint'],
},
];
describe('Insights', () => {
beforeEach(() => {
mockCanUseCases.mockReturnValue(noCasesPermissions());
});
it('does not render when there is no content to show', () => {
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={[]} scopeId="" />
</TestProviders>
);
expect(
screen.queryByRole('heading', {
name: i18n.INSIGHTS,
})
).not.toBeInTheDocument();
});
it('renders when there is at least one insight element to show', () => {
// One of the insights modules is the module showing related cases.
// It will show for all users that are able to read case data.
// Enabling that permission, will show the case insight module which
// is necessary to pass this test.
mockCanUseCases.mockReturnValue(readCasesPermissions());
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={[]} scopeId="" />
</TestProviders>
);
expect(
screen.queryByRole('heading', {
name: i18n.INSIGHTS,
})
).toBeInTheDocument();
});
describe('with feature flag enabled', () => {
describe('with platinum license', () => {
beforeAll(() => {
licenseServiceMock.isPlatinumPlus.mockReturnValue(true);
});
it('should show insights for related alerts by process ancestry', () => {
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={data} scopeId="" />
</TestProviders>
);
expect(screen.getByTestId('related-alerts-by-ancestry')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: new RegExp(i18n.INSIGHTS_UPSELL) })
).not.toBeInTheDocument();
});
describe('without process ancestry info', () => {
it('should not show the related alerts by process ancestry insights module', () => {
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={dataWithoutAgentType} scopeId="" />
</TestProviders>
);
expect(screen.queryByTestId('related-alerts-by-ancestry')).not.toBeInTheDocument();
});
});
});
describe('without platinum license', () => {
it('should show an upsell for related alerts by process ancestry', () => {
licenseServiceMock.isPlatinumPlus.mockReturnValue(false);
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={data} scopeId="" />
</TestProviders>
);
expect(
screen.getByRole('button', { name: new RegExp(i18n.INSIGHTS_UPSELL) })
).toBeInTheDocument();
expect(screen.queryByTestId('related-alerts-by-ancestry')).not.toBeInTheDocument();
});
});
});
});

View file

@ -1,189 +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 React from 'react';
import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { ALERT_SUPPRESSION_DOCS_COUNT } from '@kbn/rule-data-utils';
import { find } from 'lodash/fp';
import { APP_ID } from '../../../../../common';
import * as i18n from './translations';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { hasData } from './helpers';
import { useLicense } from '../../../hooks/use_license';
import { RelatedAlertsByProcessAncestry } from './related_alerts_by_process_ancestry';
import { RelatedCases } from './related_cases';
import { RelatedAlertsBySourceEvent } from './related_alerts_by_source_event';
import { RelatedAlertsBySession } from './related_alerts_by_session';
import { RelatedAlertsUpsell } from './related_alerts_upsell';
import { useKibana } from '../../../lib/kibana';
const StyledInsightItem = euiStyled(EuiFlexItem)`
border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
padding: 10px 8px;
border-radius: 6px;
display: inline-flex;
`;
interface Props {
browserFields: BrowserFields;
eventId: string;
data: TimelineEventsDetailsItem[];
scopeId: string;
isReadOnly?: boolean;
}
/**
* Displays several key insights for the associated alert.
*/
export const Insights = React.memo<Props>(
({ browserFields, eventId, data, isReadOnly, scopeId }) => {
const { cases } = useKibana().services;
const hasAtLeastPlatinum = useLicense().isPlatinumPlus();
const originalDocumentId = find(
{ category: 'kibana', field: 'kibana.alert.ancestors.id' },
data
);
const originalDocumentIndex = find(
{ category: 'kibana', field: 'kibana.alert.rule.parameters.index' },
data
);
const agentTypeField = find({ category: 'agent', field: 'agent.type' }, data);
const eventModuleField = find({ category: 'event', field: 'event.module' }, data);
const processEntityField = find({ category: 'process', field: 'process.entity_id' }, data);
const hasProcessEntityInfo =
hasData(processEntityField) &&
hasCorrectAgentTypeAndEventModule(agentTypeField, eventModuleField);
const processSessionField = find(
{ category: 'process', field: 'process.entry_leader.entity_id' },
data
);
const hasProcessSessionInfo = hasData(processSessionField);
const sourceEventField = find(
{ category: 'kibana', field: 'kibana.alert.original_event.id' },
data
);
const hasSourceEventInfo = hasData(sourceEventField);
const alertSuppressionField = find(
{ category: 'kibana', field: ALERT_SUPPRESSION_DOCS_COUNT },
data
);
const hasAlertSuppressionField = hasData(alertSuppressionField);
const userCasesPermissions = cases.helpers.canUseCases([APP_ID]);
const hasCasesReadPermissions = userCasesPermissions.read;
// Make sure that the alert has at least one of the associated fields
// or the user has the required permissions for features/fields that
// we can provide insights for
const canShowAtLeastOneInsight =
hasCasesReadPermissions ||
hasProcessEntityInfo ||
hasSourceEventInfo ||
hasProcessSessionInfo;
const canShowAncestryInsight =
hasProcessEntityInfo && originalDocumentId && originalDocumentIndex;
// If we're in read-only mode or don't have any insight-related data,
// don't render anything.
if (isReadOnly || !canShowAtLeastOneInsight) {
return null;
}
return (
<div>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiTitle size="xxxs">
<h5>{i18n.INSIGHTS}</h5>
</EuiTitle>
</EuiFlexItem>
{hasAlertSuppressionField && (
<StyledInsightItem>
<div>
<EuiIcon type="layers" style={{ marginLeft: '4px', marginRight: '8px' }} />
{i18n.SUPPRESSED_ALERTS_COUNT(parseInt(alertSuppressionField.values[0], 10))}
<EuiBetaBadge
label={i18n.SUPPRESSED_ALERTS_COUNT_TECHNICAL_PREVIEW}
style={{ verticalAlign: 'middle', marginLeft: '8px' }}
size="s"
/>
</div>
</StyledInsightItem>
)}
{hasCasesReadPermissions && (
<EuiFlexItem>
<RelatedCases eventId={eventId} />
</EuiFlexItem>
)}
{sourceEventField && sourceEventField.values && (
<EuiFlexItem>
<RelatedAlertsBySourceEvent
browserFields={browserFields}
data={sourceEventField}
eventId={eventId}
scopeId={scopeId}
/>
</EuiFlexItem>
)}
{processSessionField && processSessionField.values && (
<EuiFlexItem data-test-subj="related-alerts-by-session">
<RelatedAlertsBySession
browserFields={browserFields}
data={processSessionField}
eventId={eventId}
scopeId={scopeId}
/>
</EuiFlexItem>
)}
{canShowAncestryInsight &&
(hasAtLeastPlatinum ? (
<EuiFlexItem data-test-subj="related-alerts-by-ancestry">
<RelatedAlertsByProcessAncestry
originalDocumentId={originalDocumentId}
index={originalDocumentIndex}
eventId={eventId}
scopeId={scopeId}
/>
</EuiFlexItem>
) : (
<EuiFlexItem>
<RelatedAlertsUpsell />
</EuiFlexItem>
))}
</EuiFlexGroup>
</div>
);
}
);
export function hasCorrectAgentTypeAndEventModule(
agentTypeField?: TimelineEventsDetailsItem,
eventModuleField?: TimelineEventsDetailsItem
): boolean {
return (
hasData(agentTypeField) &&
(agentTypeField.values[0] === 'endpoint' ||
(agentTypeField.values[0] === 'winlogbeat' &&
hasData(eventModuleField) &&
eventModuleField.values[0] === 'sysmon'))
);
}
Insights.displayName = 'Insights';

View file

@ -1,166 +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 { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useAlertPrevalenceFromProcessTree } from '../../../containers/alerts/use_alert_prevalence_from_process_tree';
import { RelatedAlertsByProcessAncestry } from './related_alerts_by_process_ancestry';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import {
PROCESS_ANCESTRY,
PROCESS_ANCESTRY_COUNT,
PROCESS_ANCESTRY_ERROR,
PROCESS_ANCESTRY_EMPTY,
} from './translations';
import type { StatsNode } from '../../../containers/alerts/use_alert_prevalence_from_process_tree';
jest.mock('../../../containers/alerts/use_alert_prevalence_from_process_tree', () => ({
useAlertPrevalenceFromProcessTree: jest.fn(),
}));
const mockUseAlertPrevalenceFromProcessTree = useAlertPrevalenceFromProcessTree as jest.Mock;
const props = {
eventId: 'random',
data: {
field: 'testfield',
values: ['test value'],
isObjectArray: false,
},
index: {
field: 'index',
values: ['test value'],
isObjectArray: false,
},
originalDocumentId: {
field: '_id',
values: ['original'],
isObjectArray: false,
},
scopeId: 'table-test',
isActiveTimelines: false,
};
describe('RelatedAlertsByProcessAncestry', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows an accordion and does not fetch data right away', () => {
render(
<TestProviders>
<RelatedAlertsByProcessAncestry {...props} />
</TestProviders>
);
expect(screen.getByText(PROCESS_ANCESTRY)).toBeInTheDocument();
expect(mockUseAlertPrevalenceFromProcessTree).not.toHaveBeenCalled();
});
it('shows a loading indicator and starts to fetch data when clicked', () => {
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: true,
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry {...props} />
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
expect(mockUseAlertPrevalenceFromProcessTree).toHaveBeenCalled();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('shows an error message when the request fails', () => {
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: false,
error: true,
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry {...props} />
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
expect(screen.getByText(PROCESS_ANCESTRY_ERROR)).toBeInTheDocument();
});
it('renders the text with a count and a timeline button when the request works', async () => {
const mockAlertIds = ['1', '2'];
const mockStatsNodes = [
{ id: 'testid', name: 'process', parent: 'testid2' },
{ id: 'testid2', name: 'iexplore' },
];
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: false,
error: false,
alertIds: mockAlertIds,
statsNodes: mockStatsNodes,
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry {...props} />
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
await waitFor(() => {
expect(screen.getByText(PROCESS_ANCESTRY_COUNT(2))).toBeInTheDocument();
expect(
screen.getByRole('button', { name: ACTION_INVESTIGATE_IN_TIMELINE })
).toBeInTheDocument();
});
});
it('renders a special message when there are no alerts to display (empty response)', async () => {
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: false,
error: false,
alertIds: [] as string[],
statsNodes: [] as StatsNode[],
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry {...props} />
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
await waitFor(() => {
expect(screen.getByText(PROCESS_ANCESTRY_EMPTY)).toBeInTheDocument();
});
});
it('renders a special message when there are no alerts to display (undefined case)', async () => {
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: false,
error: false,
alertIds: undefined,
statsNodes: undefined,
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry {...props} />
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
await waitFor(() => {
expect(screen.getByText(PROCESS_ANCESTRY_EMPTY)).toBeInTheDocument();
});
});
});

View file

@ -1,235 +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 React, { useMemo, useCallback, useEffect, useState } from 'react';
import { EuiSpacer, EuiLoadingSpinner } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import { isActiveTimeline } from '../../../../helpers';
import type { DataProvider } from '../../../../../common/types';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { getDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalenceFromProcessTree } from '../../../containers/alerts/use_alert_prevalence_from_process_tree';
import { InsightAccordion } from './insight_accordion';
import { SimpleAlertTable } from './simple_alert_table';
import { InvestigateInTimelineButton } from '../table/investigate_in_timeline_button';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import {
PROCESS_ANCESTRY,
PROCESS_ANCESTRY_COUNT,
PROCESS_ANCESTRY_EMPTY,
PROCESS_ANCESTRY_ERROR,
PROCESS_ANCESTRY_FILTER,
} from './translations';
interface Props {
eventId: string;
index: TimelineEventsDetailsItem;
originalDocumentId: TimelineEventsDetailsItem;
scopeId?: string;
}
interface Cache {
alertIds: string[];
}
const dataProviderLimit = 5;
/**
* Fetches and displays alerts that were generated in the associated process'
* process tree.
* Offers the ability to dive deeper into the investigation by opening
* the related alerts in a timeline investigation.
*
* In contrast to other insight accordions, this one does not fetch the
* count and alerts on mount since the call to fetch the process tree
* and its associated alerts is quite expensive.
* The component requires users to click on the accordion in order to
* initiate the fetch of the associated events.
*
* In order to achieve this, this component orchestrates two helper
* components:
*
* RelatedAlertsByProcessAncestry (empty cache)
* user clicks -->
* FetchAndNotifyCachedAlertsByProcessAncestry (fetches data, shows loading state)
* cache loaded -->
* ActualRelatedAlertsByProcessAncestry (displays data)
*
* The top-level component maintains a "cache" state that is used for
* state management and to prevent double-fetching in case the
* accordion is closed and re-opened.
*
* Due to the ephemeral nature of the data, it was decided to keep the
* state inside the component rather than to add it to Redux.
*/
export const RelatedAlertsByProcessAncestry = React.memo<Props>(
({ originalDocumentId, index, eventId, scopeId }) => {
const [showContent, setShowContent] = useState(false);
const [cache, setCache] = useState<Partial<Cache>>({});
const onToggle = useCallback((isOpen: boolean) => setShowContent(isOpen), []);
// Makes sure the component is not fetching data before the accordion
// has been openend.
const renderContent = useCallback(() => {
if (!showContent) {
return null;
} else if (cache.alertIds) {
return (
<ActualRelatedAlertsByProcessAncestry
eventId={eventId}
scopeId={scopeId}
alertIds={cache.alertIds}
/>
);
}
return (
<FetchAndNotifyCachedAlertsByProcessAncestry
index={index}
originalDocumentId={originalDocumentId}
eventId={eventId}
isActiveTimelines={isActiveTimeline(scopeId ?? '')}
onCacheLoad={setCache}
/>
);
}, [showContent, cache.alertIds, index, originalDocumentId, eventId, scopeId]);
return (
<InsightAccordion
prefix="RelatedAlertsByProcessAncestry"
// `renderContent` and the associated sub-components are making sure to
// render the correct loading and error states so we can omit these states here
state="success"
text={
// If we have fetched the alerts, display the count here, otherwise omit the count
cache.alertIds ? PROCESS_ANCESTRY_COUNT(cache.alertIds.length) : PROCESS_ANCESTRY
}
renderContent={renderContent}
onToggle={onToggle}
/>
);
}
);
RelatedAlertsByProcessAncestry.displayName = 'RelatedAlertsByProcessAncestry';
/**
* Fetches data, displays a loading and error state and notifies about on success
*/
const FetchAndNotifyCachedAlertsByProcessAncestry: React.FC<{
eventId: string;
index: TimelineEventsDetailsItem;
originalDocumentId: TimelineEventsDetailsItem;
isActiveTimelines: boolean;
onCacheLoad: (cache: Cache) => void;
}> = ({ originalDocumentId, index, isActiveTimelines, onCacheLoad, eventId }) => {
const { values: indices } = index;
const { values: wrappedDocumentId } = originalDocumentId;
const documentId = Array.isArray(wrappedDocumentId) ? wrappedDocumentId[0] : '';
const { loading, error, alertIds } = useAlertPrevalenceFromProcessTree({
isActiveTimeline: isActiveTimelines,
documentId,
indices: indices ?? [],
});
useEffect(() => {
if (alertIds && alertIds.length !== 0) {
onCacheLoad({ alertIds });
}
}, [alertIds, onCacheLoad]);
if (loading) {
return <EuiLoadingSpinner />;
} else if (error) {
return <>{PROCESS_ANCESTRY_ERROR}</>;
} else if (!alertIds || alertIds.length === 0) {
return <>{PROCESS_ANCESTRY_EMPTY}</>;
}
return null;
};
FetchAndNotifyCachedAlertsByProcessAncestry.displayName =
'FetchAndNotifyCachedAlertsByProcessAncestry';
/**
* Renders the alert table and the timeline button from a filled cache.
*/
const ActualRelatedAlertsByProcessAncestry: React.FC<{
alertIds: string[];
eventId: string;
scopeId?: string;
}> = ({ alertIds, eventId, scopeId }) => {
const shouldUseFilters = alertIds && alertIds.length && alertIds.length >= dataProviderLimit;
const dataProviders = useMemo(() => {
if (alertIds && alertIds.length) {
if (shouldUseFilters) {
return null;
} else {
return alertIds.reduce<DataProvider[]>((result, alertId, index) => {
const id = `${scopeId}-${eventId}-event.id-${index}-${alertId}`;
result.push(getDataProvider('_id', id, alertId));
return result;
}, []);
}
}
return null;
}, [alertIds, shouldUseFilters, scopeId, eventId]);
const filters: Filter[] | null = useMemo(() => {
if (shouldUseFilters) {
return [
{
meta: {
alias: PROCESS_ANCESTRY_FILTER,
type: 'phrases',
key: '_id',
params: [...alertIds],
negate: false,
disabled: false,
value: alertIds.join(),
},
query: {
bool: {
should: alertIds.map((id) => {
return {
match_phrase: {
_id: id,
},
};
}),
minimum_should_match: 1,
},
},
},
];
} else {
return null;
}
}, [alertIds, shouldUseFilters]);
if (!dataProviders && !filters) {
return null;
}
return (
<>
<SimpleAlertTable alertIds={alertIds} />
<EuiSpacer />
<InvestigateInTimelineButton
asEmptyButton={false}
dataProviders={dataProviders}
filters={filters}
data-test-subj="investigate-ancestry-in-timeline"
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</InvestigateInTimelineButton>
</>
);
};
ActualRelatedAlertsByProcessAncestry.displayName = 'ActualRelatedAlertsByProcessAncestry';

View file

@ -1,122 +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 { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import { RelatedAlertsBySession } from './related_alerts_by_session';
import { SESSION_LOADING, SESSION_ERROR, SESSION_COUNT } from './translations';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
jest.mock('../table/use_action_cell_data_provider', () => ({
useActionCellDataProvider: jest.fn(),
}));
const mockUseActionCellDataProvider = useActionCellDataProvider as jest.Mock;
jest.mock('../../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: jest.fn(),
}));
const mockUseAlertPrevalence = useAlertPrevalence as jest.Mock;
const testEventId = '20398h209482';
const testData = {
field: 'process.entry_leader.entity_id',
data: ['2938hr29348h9489r8'],
isObjectArray: false,
};
describe('RelatedAlertsBySession', () => {
it('shows a loading message when data is loading', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
expect(screen.getByText(SESSION_LOADING)).toBeInTheDocument();
});
it('shows an error message when data failed to load', () => {
mockUseAlertPrevalence.mockReturnValue({
error: true,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
expect(screen.getByText(SESSION_ERROR)).toBeInTheDocument();
});
it('shows an empty state when no alerts exist', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 0,
alertIds: [],
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
expect(screen.getByText(SESSION_COUNT(0))).toBeInTheDocument();
});
it('shows the correct count and renders the timeline button', async () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 2,
alertIds: ['223', '2323'],
});
mockUseActionCellDataProvider.mockReturnValue({
dataProviders: [{}, {}],
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
await waitFor(() => {
expect(screen.getByText(SESSION_COUNT(2))).toBeInTheDocument();
expect(screen.getByText(ACTION_INVESTIGATE_IN_TIMELINE)).toBeInTheDocument();
});
});
});

View file

@ -1,123 +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 React, { useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { isActiveTimeline } from '../../../../helpers';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import type { InsightAccordionState } from './insight_accordion';
import { InsightAccordion } from './insight_accordion';
import { InvestigateInTimelineButton } from '../table/investigate_in_timeline_button';
import { SimpleAlertTable } from './simple_alert_table';
import { getEnrichedFieldInfo } from '../helpers';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import { SESSION_LOADING, SESSION_EMPTY, SESSION_ERROR, SESSION_COUNT } from './translations';
import { getFieldFormat } from '../get_field_format';
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem;
eventId: string;
scopeId: string;
}
/**
* Fetches the count of alerts that were generated in the same session
* and displays an accordion with a mini table representation of the
* related cases.
* Offers the ability to dive deeper into the investigation by opening
* the related alerts in a timeline investigation.
*/
export const RelatedAlertsBySession = React.memo<Props>(
({ browserFields, data, eventId, scopeId }) => {
const { field, values } = data;
const { error, count, alertIds } = useAlertPrevalence({
field,
value: values,
isActiveTimelines: isActiveTimeline(scopeId),
signalIndexName: null,
includeAlertIds: true,
ignoreTimerange: true,
});
const { fieldFromBrowserField } = getEnrichedFieldInfo({
browserFields,
contextId: scopeId,
eventId,
field: { id: data.field },
scopeId,
item: data,
});
const cellData = useActionCellDataProvider({
field,
values,
contextId: scopeId,
eventId,
fieldFromBrowserField,
fieldFormat: getFieldFormat(fieldFromBrowserField),
fieldType: fieldFromBrowserField?.type,
});
const isEmpty = count === 0;
let state: InsightAccordionState = 'loading';
if (error) {
state = 'error';
} else if (alertIds || isEmpty) {
state = 'success';
}
const renderContent = useCallback(() => {
if (!alertIds || !cellData?.dataProviders) {
return null;
} else if (isEmpty && state !== 'loading') {
return SESSION_EMPTY;
}
return (
<>
<SimpleAlertTable alertIds={alertIds} />
<EuiSpacer />
<InvestigateInTimelineButton
asEmptyButton={false}
dataProviders={cellData?.dataProviders}
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</InvestigateInTimelineButton>
</>
);
}, [alertIds, cellData?.dataProviders, isEmpty, state]);
return (
<InsightAccordion
prefix="RelatedAlertsBySession"
state={state}
text={getTextFromState(state, count)}
renderContent={renderContent}
/>
);
}
);
RelatedAlertsBySession.displayName = 'RelatedAlertsBySession';
function getTextFromState(state: InsightAccordionState, count: number | undefined) {
switch (state) {
case 'loading':
return SESSION_LOADING;
case 'error':
return SESSION_ERROR;
case 'success':
return SESSION_COUNT(count);
default:
return '';
}
}

View file

@ -1,122 +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 { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import { RelatedAlertsBySourceEvent } from './related_alerts_by_source_event';
import { SOURCE_EVENT_LOADING, SOURCE_EVENT_ERROR, SOURCE_EVENT_COUNT } from './translations';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
jest.mock('../table/use_action_cell_data_provider', () => ({
useActionCellDataProvider: jest.fn(),
}));
const mockUseActionCellDataProvider = useActionCellDataProvider as jest.Mock;
jest.mock('../../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: jest.fn(),
}));
const mockUseAlertPrevalence = useAlertPrevalence as jest.Mock;
const testEventId = '20398h209482';
const testData = {
field: 'kibana.alert.original_event.id',
data: ['2938hr29348h9489r8'],
isObjectArray: false,
};
describe('RelatedAlertsBySourceEvent', () => {
it('shows a loading message when data is loading', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
expect(screen.getByText(SOURCE_EVENT_LOADING)).toBeInTheDocument();
});
it('shows an error message when data failed to load', () => {
mockUseAlertPrevalence.mockReturnValue({
error: true,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
expect(screen.getByText(SOURCE_EVENT_ERROR)).toBeInTheDocument();
});
it('shows an empty state when no alerts exist', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 0,
alertIds: [],
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
expect(screen.getByText(SOURCE_EVENT_COUNT(0))).toBeInTheDocument();
});
it('shows the correct count and renders the timeline button', async () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 2,
alertIds: ['223', '2323'],
});
mockUseActionCellDataProvider.mockReturnValue({
dataProviders: [{}, {}],
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
scopeId=""
/>
</TestProviders>
);
await waitFor(() => {
expect(screen.getByText(SOURCE_EVENT_COUNT(2))).toBeInTheDocument();
expect(screen.getByText(ACTION_INVESTIGATE_IN_TIMELINE)).toBeInTheDocument();
});
});
});

View file

@ -1,127 +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 React, { useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { isActiveTimeline } from '../../../../helpers';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import type { InsightAccordionState } from './insight_accordion';
import { InsightAccordion } from './insight_accordion';
import { InvestigateInTimelineButton } from '../table/investigate_in_timeline_button';
import { SimpleAlertTable } from './simple_alert_table';
import { getEnrichedFieldInfo } from '../helpers';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import {
SOURCE_EVENT_LOADING,
SOURCE_EVENT_EMPTY,
SOURCE_EVENT_ERROR,
SOURCE_EVENT_COUNT,
} from './translations';
import { getFieldFormat } from '../get_field_format';
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem;
eventId: string;
scopeId: string;
}
/**
* Fetches the count of alerts that were generated by the same source
* event and displays an accordion with a mini table representation of
* the related cases.
* Offers the ability to dive deeper into the investigation by opening
* the related alerts in a timeline investigation.
*/
export const RelatedAlertsBySourceEvent = React.memo<Props>(
({ browserFields, data, eventId, scopeId }) => {
const { field, values } = data;
const { error, count, alertIds } = useAlertPrevalence({
field,
value: values,
isActiveTimelines: isActiveTimeline(scopeId),
signalIndexName: null,
includeAlertIds: true,
});
const { fieldFromBrowserField } = getEnrichedFieldInfo({
browserFields,
contextId: scopeId,
eventId,
field: { id: data.field },
scopeId,
item: data,
});
const cellData = useActionCellDataProvider({
field,
values,
contextId: scopeId,
eventId,
fieldFromBrowserField,
fieldFormat: getFieldFormat(fieldFromBrowserField),
fieldType: fieldFromBrowserField?.type,
});
const isEmpty = count === 0;
let state: InsightAccordionState = 'loading';
if (error) {
state = 'error';
} else if (alertIds) {
state = 'success';
}
const renderContent = useCallback(() => {
if (!alertIds || !cellData?.dataProviders) {
return null;
} else if (isEmpty && state !== 'loading') {
return SOURCE_EVENT_EMPTY;
}
return (
<>
<SimpleAlertTable alertIds={alertIds} />
<EuiSpacer />
<InvestigateInTimelineButton
asEmptyButton={false}
dataProviders={cellData?.dataProviders}
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</InvestigateInTimelineButton>
</>
);
}, [alertIds, cellData?.dataProviders, isEmpty, state]);
return (
<InsightAccordion
prefix="RelatedAlertsBySourceEvent"
state={state}
text={getTextFromState(state, count)}
renderContent={renderContent}
/>
);
}
);
function getTextFromState(state: InsightAccordionState, count: number | undefined) {
switch (state) {
case 'loading':
return SOURCE_EVENT_LOADING;
case 'error':
return SOURCE_EVENT_ERROR;
case 'success':
return SOURCE_EVENT_COUNT(count);
default:
return '';
}
}
RelatedAlertsBySourceEvent.displayName = 'RelatedAlertsBySourceEvent';

View file

@ -1,51 +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 React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiText } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { INSIGHTS_UPSELL } from './translations';
import { useKibana } from '../../../lib/kibana';
const UpsellContainer = euiStyled.div`
border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
padding: 12px;
border-radius: 6px;
`;
const StyledIcon = euiStyled(EuiIcon)`
margin-right: 10px;
`;
export const RelatedAlertsUpsell = React.memo(() => {
const { application } = useKibana().services;
return (
<UpsellContainer>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<StyledIcon size="m" type="lock" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<EuiLink
color="subdued"
target="_blank"
href={application.getUrlForApp('management', {
path: 'stack/license_management/home',
})}
>
{INSIGHTS_UPSELL}
</EuiLink>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</UpsellContainer>
);
});
RelatedAlertsUpsell.displayName = 'RelatedAlertsUpsell';

View file

@ -1,148 +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 { act, render, screen } from '@testing-library/react';
import React from 'react';
import { casesPluginMock } from '@kbn/cases-plugin/public/mocks';
import { TestProviders } from '../../../mock';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
import { RelatedCases } from './related_cases';
import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
import { CASES_LOADING, CASES_COUNT } from './translations';
import { useTourContext } from '../../guided_onboarding_tour';
import { AlertsCasesTourSteps } from '../../guided_onboarding_tour/tour_config';
const mockedUseKibana = mockUseKibana();
const mockCasesContract = casesPluginMock.createStartContract();
const mockGetRelatedCases = mockCasesContract.api.getRelatedCases as jest.Mock;
mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
const mockCanUseCases = mockCasesContract.helpers.canUseCases as jest.Mock;
mockCanUseCases.mockReturnValue(readCasesPermissions());
const mockUseTourContext = useTourContext as jest.Mock;
jest.mock('../../../lib/kibana', () => {
const original = jest.requireActual('../../../lib/kibana');
return {
...original,
useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }),
useKibana: () => ({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
cases: mockCasesContract,
},
}),
};
});
jest.mock('../../guided_onboarding_tour');
const defaultUseTourContextValue = {
activeStep: AlertsCasesTourSteps.viewCase,
incrementStep: () => null,
endTourStep: () => null,
isTourShown: () => false,
};
jest.mock('../../guided_onboarding_tour/tour_step');
const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27a';
const scrollToMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollToMock;
describe('Related Cases', () => {
beforeEach(() => {
mockUseTourContext.mockReturnValue(defaultUseTourContextValue);
jest.clearAllMocks();
});
describe('When user does not have cases read permissions', () => {
beforeEach(() => {
mockCanUseCases.mockReturnValue(noCasesPermissions());
});
test('should not show related cases when user does not have permissions', async () => {
await act(async () => {
render(<RelatedCases eventId={eventId} />, { wrapper: TestProviders });
});
expect(screen.queryByText('cases')).toBeNull();
});
});
describe('When user does have case read permissions', () => {
beforeEach(() => {
mockCanUseCases.mockReturnValue(readCasesPermissions());
});
test('Should show the loading message', async () => {
mockGetRelatedCases.mockReturnValueOnce([]);
await act(async () => {
render(<RelatedCases eventId={eventId} />, { wrapper: TestProviders });
expect(screen.queryByText(CASES_LOADING)).toBeInTheDocument();
});
expect(screen.queryByText(CASES_LOADING)).not.toBeInTheDocument();
});
test('Should show 0 related cases when there are none', async () => {
mockGetRelatedCases.mockReturnValueOnce([]);
await act(async () => {
render(<RelatedCases eventId={eventId} />, { wrapper: TestProviders });
});
expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument();
});
test('Should show 1 related case', async () => {
await act(async () => {
render(<RelatedCases eventId={eventId} />, { wrapper: TestProviders });
});
expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument();
expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case');
});
test('Should show 2 related cases', async () => {
mockGetRelatedCases.mockReturnValueOnce([
{ id: '789', title: 'Test Case 1' },
{ id: '456', title: 'Test Case 2' },
]);
await act(async () => {
render(<RelatedCases eventId={eventId} />, { wrapper: TestProviders });
});
expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument();
const cases = screen.getAllByTestId('case-details-link');
expect(cases).toHaveLength(2);
expect(cases[0]).toHaveTextContent('Test Case 1');
expect(cases[1]).toHaveTextContent('Test Case 2');
});
test('Should not open the related cases accordion when isTourActive=false', async () => {
await act(async () => {
render(<RelatedCases eventId={eventId} />, { wrapper: TestProviders });
});
expect(scrollToMock).not.toHaveBeenCalled();
expect(
screen.getByTestId('RelatedCases-accordion').classList.contains('euiAccordion-isOpen')
).toBe(false);
});
test('Should automatically open the related cases accordion when isTourActive=true', async () => {
// this hook is called twice, so we can not use mockReturnValueOnce
mockUseTourContext.mockReturnValue({
...defaultUseTourContextValue,
isTourShown: () => true,
});
await act(async () => {
render(<RelatedCases eventId={eventId} />, { wrapper: TestProviders });
});
expect(scrollToMock).toHaveBeenCalled();
expect(
screen.getByTestId('RelatedCases-accordion').classList.contains('euiAccordion-isOpen')
).toBe(true);
});
});
});

View file

@ -1,149 +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 React, { useCallback, useState, useEffect, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { AlertsCasesTourSteps, SecurityStepId } from '../../guided_onboarding_tour/tour_config';
import { useTourContext } from '../../guided_onboarding_tour';
import { useKibana, useToasts } from '../../../lib/kibana';
import { CaseDetailsLink } from '../../links';
import { APP_ID } from '../../../../../common/constants';
import type { InsightAccordionState } from './insight_accordion';
import { InsightAccordion } from './insight_accordion';
import { CASES_LOADING, CASES_ERROR, CASES_ERROR_TOAST, CASES_COUNT } from './translations';
type RelatedCaseList = Array<{ id: string; title: string }>;
interface Props {
eventId: string;
}
/**
* Fetches and displays case links of cases that include the associated event (id).
*/
export const RelatedCases = React.memo<Props>(({ eventId }) => {
const {
services: { cases },
} = useKibana();
const toasts = useToasts();
const [relatedCases, setRelatedCases] = useState<RelatedCaseList | undefined>(undefined);
const [hasError, setHasError] = useState<boolean>(false);
const { activeStep, isTourShown } = useTourContext();
const isTourActive = useMemo(
() => activeStep === AlertsCasesTourSteps.viewCase && isTourShown(SecurityStepId.alertsCases),
[activeStep, isTourShown]
);
const renderContent = useCallback(() => renderCaseContent(relatedCases), [relatedCases]);
const [shouldFetch, setShouldFetch] = useState<boolean>(false);
useEffect(() => {
if (!shouldFetch) {
return;
}
let ignore = false;
const fetch = async () => {
let relatedCaseList: RelatedCaseList = [];
try {
if (eventId) {
relatedCaseList =
(await cases.api.getRelatedCases(eventId, {
owner: APP_ID,
})) ?? [];
}
} catch (error) {
if (!ignore) {
setHasError(true);
}
toasts.addWarning(CASES_ERROR_TOAST(error));
}
if (!ignore) {
setRelatedCases(relatedCaseList);
setShouldFetch(false);
}
};
fetch();
return () => {
ignore = true;
};
}, [cases.api, eventId, shouldFetch, toasts]);
useEffect(() => {
setShouldFetch(true);
}, [eventId]);
let state: InsightAccordionState = 'loading';
if (hasError) {
state = 'error';
} else if (relatedCases) {
state = 'success';
}
return (
<InsightAccordion
prefix="RelatedCases"
state={state}
text={getTextFromState(state, relatedCases?.length)}
renderContent={renderContent}
forceState={isTourActive ? 'open' : undefined}
/>
);
});
function renderCaseContent(relatedCases: RelatedCaseList = []) {
const caseCount = relatedCases.length;
return (
<span>
<FormattedMessage
defaultMessage="This alert was found in {caseCount}"
id="xpack.securitySolution.alertDetails.overview.insights_related_cases_found_content"
values={{
caseCount: (
<strong>
<FormattedMessage
id="xpack.securitySolution.alertDetails.overview.insights_related_cases_found_content_count"
defaultMessage="{caseCount} {caseCount, plural, =0 {cases.} =1 {case:} other {cases:}}"
values={{ caseCount }}
/>
</strong>
),
}}
/>
{relatedCases.map(({ id, title }, index) =>
id && title ? (
<span key={id}>
{' '}
<CaseDetailsLink detailName={id} title={title} index={index}>
{title}
</CaseDetailsLink>
{relatedCases[index + 1] ? ',' : ''}
</span>
) : (
<></>
)
)}
</span>
);
}
RelatedCases.displayName = 'RelatedCases';
function getTextFromState(state: InsightAccordionState, caseCount = 0) {
switch (state) {
case 'loading':
return CASES_LOADING;
case 'error':
return CASES_ERROR;
case 'success':
return CASES_COUNT(caseCount);
default:
return '';
}
}

View file

@ -1,111 +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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useAlertsByIds } from '../../../containers/alerts/use_alerts_by_ids';
import { SimpleAlertTable } from './simple_alert_table';
jest.mock('../../../containers/alerts/use_alerts_by_ids', () => ({
useAlertsByIds: jest.fn(),
}));
const mockUseAlertsByIds = useAlertsByIds as jest.Mock;
const testIds = ['wer34r34', '234234'];
const tooManyTestIds = [
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
];
const testResponse = [
{
fields: {
'kibana.alert.rule.name': ['test rule name'],
'@timestamp': ['2022-07-18T15:07:21.753Z'],
'kibana.alert.severity': ['high'],
},
},
];
describe('SimpleAlertTable', () => {
it('shows a loading indicator when the data is loading', () => {
mockUseAlertsByIds.mockReturnValue({
loading: true,
error: false,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={testIds} />
</TestProviders>
);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('shows an error message when there was an error fetching the alerts', () => {
mockUseAlertsByIds.mockReturnValue({
loading: false,
error: true,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={testIds} />
</TestProviders>
);
expect(screen.getByText(/Failed/)).toBeInTheDocument();
});
it('shows the results', () => {
mockUseAlertsByIds.mockReturnValue({
loading: false,
error: false,
data: testResponse,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={testIds} />
</TestProviders>
);
// Renders to table headers
expect(screen.getByRole('columnheader', { name: 'Rule' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '@timestamp' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Severity' })).toBeInTheDocument();
// Renders the row
expect(screen.getByText('test rule name')).toBeInTheDocument();
expect(screen.getByText(/Jul 18/)).toBeInTheDocument();
expect(screen.getByText('High')).toBeInTheDocument();
});
it('shows a note about limited results', () => {
mockUseAlertsByIds.mockReturnValue({
loading: false,
error: false,
data: testResponse,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={tooManyTestIds} />
</TestProviders>
);
expect(screen.getByText(/Showing only/)).toBeInTheDocument();
});
});

View file

@ -1,80 +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 React, { useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { EuiBasicTable, EuiSkeletonText, EuiSpacer } from '@elastic/eui';
import { PreferenceFormattedDate } from '../../formatted_date';
import { SeverityBadge } from '../../severity_badge';
import { useAlertsByIds } from '../../../containers/alerts/use_alerts_by_ids';
import { SIMPLE_ALERT_TABLE_ERROR, SIMPLE_ALERT_TABLE_LIMITED } from './translations';
const TABLE_FIELDS = ['@timestamp', 'kibana.alert.rule.name', 'kibana.alert.severity'];
const columns: Array<EuiBasicTableColumn<Record<string, string[]>>> = [
{
field: 'kibana.alert.rule.name',
name: 'Rule',
},
{
field: '@timestamp',
name: '@timestamp',
render: (timestamp: string) => <PreferenceFormattedDate value={new Date(timestamp)} />,
},
{
field: 'kibana.alert.severity',
name: 'Severity',
render: (severity: Severity) => <SeverityBadge value={severity} />,
},
];
/** 10 alert rows in this table has been deemed a balanced amount for the flyout */
const alertLimit = 10;
/**
* Displays a simplified alert table for the given alert ids.
* It will only fetch the latest 10 ids and in case more ids
* are passed in, it will add a note about omitted alerts.
*/
export const SimpleAlertTable = React.memo<{ alertIds: string[] }>(({ alertIds }) => {
const sampledData = useMemo(() => alertIds.slice(0, alertLimit), [alertIds]);
const { loading, error, data } = useAlertsByIds({
alertIds: sampledData,
fields: TABLE_FIELDS,
});
const mappedData = useMemo(() => {
if (!data) {
return undefined;
}
return data.map((doc) => doc.fields);
}, [data]);
if (loading) {
return <EuiSkeletonText lines={2} />;
} else if (error) {
return <>{SIMPLE_ALERT_TABLE_ERROR}</>;
} else if (mappedData) {
const showLimitedDataNote = alertIds.length > alertLimit;
return (
<>
{showLimitedDataNote && (
<div>
<em>{SIMPLE_ALERT_TABLE_LIMITED}</em>
<EuiSpacer />
</div>
)}
<EuiBasicTable compressed={true} items={mappedData} columns={columns} />
</>
);
}
return null;
});
SimpleAlertTable.displayName = 'SimpleAlertTable';

View file

@ -7,155 +7,7 @@
import { i18n } from '@kbn/i18n';
export const INSIGHTS = i18n.translate('xpack.securitySolution.alertDetails.overview.insights', {
defaultMessage: 'Insights',
});
export const PROCESS_ANCESTRY = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_process_ancestry',
{
defaultMessage: 'Related alerts by process ancestry',
}
);
export const PROCESS_ANCESTRY_COUNT = (count: number) =>
i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_process_ancestry_count',
{
defaultMessage: '{count} {count, plural, =1 {alert} other {alerts}} by process ancestry',
values: { count },
}
);
export const PROCESS_ANCESTRY_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_process_ancestry_error',
{
defaultMessage: 'Failed to fetch alerts.',
}
);
export const PROCESS_ANCESTRY_FILTER = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.processAncestryFilter',
{
defaultMessage: 'Process Ancestry Alert IDs',
}
);
export const PROCESS_ANCESTRY_EMPTY = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_process_ancestry_empty',
{
defaultMessage: 'There are no related alerts by process ancestry.',
}
);
export const SESSION_LOADING = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_source_event_loading',
{ defaultMessage: 'Loading related alerts by source event' }
);
export const SESSION_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_session_error',
{
defaultMessage: 'Failed to load related alerts by session',
}
);
export const SESSION_EMPTY = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_session_empty',
{
defaultMessage: 'There are no related alerts by session',
}
);
export const SESSION_COUNT = (count?: number) =>
i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_session_count',
{
defaultMessage: '{count} {count, plural, =1 {alert} other {alerts}} related by session',
values: { count },
}
);
export const SOURCE_EVENT_LOADING = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_source_event_loading',
{ defaultMessage: 'Loading related alerts by source event' }
);
export const SOURCE_EVENT_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_source_event_error',
{
defaultMessage: 'Failed to load related alerts by source event',
}
);
export const SOURCE_EVENT_EMPTY = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_source_event_empty',
{
defaultMessage: 'There are no related alerts by source event',
}
);
export const SOURCE_EVENT_COUNT = (count?: number) =>
i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights_related_alerts_by_source_event_count',
{
defaultMessage: '{count} {count, plural, =1 {alert} other {alerts}} related by source event',
values: { count },
}
);
export const CASES_LOADING = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_cases_loading',
{
defaultMessage: 'Loading related cases',
}
);
export const CASES_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_cases_error',
{
defaultMessage: 'Failed to load related cases',
}
);
export const CASES_COUNT = (count: number) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.insights.related_cases_count', {
defaultMessage: '{count} {count, plural, =1 {case} other {cases}} related to this alert',
values: { count },
});
export const CASES_ERROR_TOAST = (error: string) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.insights.relatedCasesFailure', {
defaultMessage: 'Unable to load related cases: "{error}"',
values: { error },
});
export const SIMPLE_ALERT_TABLE_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.simpleAlertTable.error',
{
defaultMessage: 'Failed to load the alerts.',
}
);
export const SIMPLE_ALERT_TABLE_LIMITED = i18n.translate(
'xpack.securitySolution.alertDetails.overview.limitedAlerts',
{
defaultMessage: 'Showing only the latest 10 alerts. View the rest of alerts in timeline.',
}
);
export const INSIGHTS_UPSELL = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.alertUpsellTitle',
{
defaultMessage: 'Get more insights with a platinum subscription',
}
);
export const SUPPRESSED_ALERTS_COUNT = (count?: number) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.insights.suppressedAlertsCount', {
defaultMessage: '{count} suppressed {count, plural, =1 {alert} other {alerts}}',
values: { count },
});
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const SUPPRESSED_ALERTS_COUNT_TECHNICAL_PREVIEW = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.suppressedAlertsCountTechnicalPreview',
{

View file

@ -43,6 +43,7 @@ interface InvestigationGuideViewProps {
/**
* Investigation guide that shows the markdown text of rule.note
*/
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
const InvestigationGuideViewComponent: React.FC<InvestigationGuideViewProps> = ({
basicData,
ruleNote,

View file

@ -1,22 +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 { shallow } from 'enzyme';
import React from 'react';
import { rawEventData } from '../../mock';
import { JsonView } from './json_view';
describe('JSON View', () => {
describe('rendering', () => {
test('should match snapshot', () => {
const wrapper = shallow(<JsonView rawEventData={rawEventData} />);
expect(wrapper).toMatchSnapshot();
});
});
});

View file

@ -1,50 +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 { EuiCodeBlock } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { omitTypenameAndEmpty } from '../../../timelines/components/timeline/body/helpers';
interface Props {
rawEventData: object | undefined;
}
const EuiCodeEditorContainer = styled.div`
.euiCodeEditorWrapper {
position: absolute;
}
`;
export const JsonView = React.memo<Props>(({ rawEventData }) => {
const value = useMemo(
() =>
JSON.stringify(
rawEventData,
omitTypenameAndEmpty,
2 // indent level
),
[rawEventData]
);
return (
<EuiCodeEditorContainer>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="m"
isCopyable
data-test-subj="jsonView"
>
{value}
</EuiCodeBlock>
</EuiCodeEditorContainer>
);
});
JsonView.displayName = 'JsonView';

View file

@ -27,6 +27,7 @@ const TabContentWrapper = styled.div`
position: relative;
`;
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const useOsqueryTab = ({
rawEventData,
ecsData,

View file

@ -1,189 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
<DocumentFragment>
.c3 {
text-transform: capitalize;
}
.c4 {
margin-left: 8px;
}
.c1.c1.c1 {
background-color: #25262e;
padding: 8px;
height: 78px;
}
.c1:hover .inlineActions {
opacity: 1;
width: auto;
-webkit-transform: translate(0);
-ms-transform: translate(0);
transform: translate(0);
}
.c1 .inlineActions {
opacity: 0;
width: 0;
-webkit-transform: translate(6px);
-ms-transform: translate(6px);
transform: translate(6px);
-webkit-transition: -webkit-transform 50ms ease-in-out;
-webkit-transition: transform 50ms ease-in-out;
transition: transform 50ms ease-in-out;
}
.c1 .inlineActions.inlineActions-popoverOpen {
opacity: 1;
width: auto;
-webkit-transform: translate(0);
-ms-transform: translate(0);
transform: translate(0);
}
.c2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.c0 {
-webkit-box-flex: 0;
-webkit-flex-grow: 0;
-ms-flex-positive: 0;
flex-grow: 0;
}
<div>
<div
class="euiFlexGroup c0 emotion-euiFlexGroup-responsive-s-flexStart-stretch-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-grow-1"
>
<div
class="euiPanel euiPanel--plain euiPanel--paddingSmall c1 emotion-euiPanel-grow-none-s-plain"
>
<div
class="euiText emotion-euiText-s"
>
Status
</div>
<div
class="euiSpacer euiSpacer--s emotion-euiSpacer-s"
/>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
>
<div
class="c2"
>
<div
class="euiPopover emotion-euiPopover-inline-block"
data-test-subj="alertStatus"
>
<button
aria-label="Click to change alert status"
class="euiBadge c3 emotion-euiBadge-primary-clickable"
data-test-subj="rule-status-badge"
>
<span
class="euiBadge__content emotion-euiBadge__content"
>
<span
class="euiBadge__text emotion-euiBadge__text-clickable"
>
open
</span>
<span
class="euiBadge__icon emotion-euiBadge__icon-right"
color="inherit"
data-euiicon-type="arrowDown"
/>
</span>
</button>
</div>
</div>
<div
class="c4"
/>
</div>
</div>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-grow-1"
>
<div
class="euiPanel euiPanel--plain euiPanel--paddingSmall c1 emotion-euiPanel-grow-none-s-plain"
>
<div
class="euiText emotion-euiText-s"
>
Risk Score
</div>
<div
class="euiSpacer euiSpacer--s emotion-euiSpacer-s"
/>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
>
<div
class="c2"
data-test-subj="riskScore"
>
47
</div>
<div
class="c4"
/>
</div>
</div>
</div>
</div>
<div
class="euiSpacer euiSpacer--s emotion-euiSpacer-s"
/>
<div
class="euiFlexGroup c0 emotion-euiFlexGroup-responsive-s-flexStart-stretch-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-grow-1"
>
<div
class="euiPanel euiPanel--plain euiPanel--paddingSmall c1 emotion-euiPanel-grow-none-s-plain"
>
<div
class="euiText emotion-euiText-s"
>
Rule
</div>
<div
class="euiSpacer euiSpacer--s emotion-euiSpacer-s"
/>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
>
<div
class="c2"
>
<button
class="euiLink emotion-euiLink-primary"
data-test-subj="ruleName"
type="button"
>
More than one event with user name
</button>
</div>
<div
class="c4"
/>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -1,230 +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 React from 'react';
import { act, render } from '@testing-library/react';
import { Overview } from '.';
import { TestProviders } from '../../../mock';
jest.mock('../../../lib/kibana');
jest.mock('../../utils', () => ({
useThrottledResizeObserver: () => ({ width: 400 }), // force row-chunking
}));
jest.mock(
'../../../../detections/containers/detection_engine/alerts/use_alerts_privileges',
() => ({
useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }),
})
);
describe('Event Details Overview Cards', () => {
it('renders all cards', async () => {
await act(async () => {
const { getByText } = render(
<TestProviders>
<Overview {...props} />
</TestProviders>
);
getByText('Status');
getByText('Severity');
getByText('Risk Score');
getByText('Rule');
});
});
it('renders only readOnly cards', async () => {
await act(async () => {
const { getByText, queryByText } = render(
<TestProviders>
<Overview {...propsWithReadOnly} />
</TestProviders>
);
getByText('Severity');
getByText('Risk Score');
expect(queryByText('Status')).not.toBeInTheDocument();
expect(queryByText('Rule')).not.toBeInTheDocument();
});
});
it('renders all cards it has data for', async () => {
await act(async () => {
const { getByText, queryByText } = render(
<TestProviders>
<Overview {...propsWithoutSeverity} />
</TestProviders>
);
getByText('Status');
getByText('Risk Score');
getByText('Rule');
expect(queryByText('Severity')).not.toBeInTheDocument();
});
});
it('renders rows and spacers correctly', async () => {
await act(async () => {
const { asFragment } = render(
<TestProviders>
<Overview {...propsWithoutSeverity} />
</TestProviders>
);
expect(asFragment()).toMatchSnapshot();
});
});
});
const props = {
handleOnEventClosed: jest.fn(),
contextId: 'alerts-page',
eventId: 'testId',
indexName: 'testIndex',
scopeId: 'page',
data: [
{
category: 'kibana',
field: 'kibana.alert.risk_score',
values: ['47'],
originalValue: ['47'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
values: ['d9f537c0-47b2-11ec-9517-c1c68c44dec0'],
originalValue: ['d9f537c0-47b2-11ec-9517-c1c68c44dec0'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.workflow_status',
values: ['open'],
originalValue: ['open'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.rule.name',
values: ['More than one event with user name'],
originalValue: ['More than one event with user name'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.severity',
values: ['medium'],
originalValue: ['medium'],
isObjectArray: false,
},
],
browserFields: {
kibana: {
fields: {
'kibana.alert.severity': {
category: 'kibana',
count: 0,
name: 'kibana.alert.severity',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: { id: 'string' },
shortDotsEnable: false,
isMapped: true,
indexes: ['apm-*-transaction*'],
},
'kibana.alert.risk_score': {
category: 'kibana',
count: 0,
name: 'kibana.alert.risk_score',
type: 'number',
esTypes: ['float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: { id: 'number' },
shortDotsEnable: false,
isMapped: true,
indexes: ['apm-*-transaction*'],
},
'kibana.alert.rule.uuid': {
category: 'kibana',
count: 0,
name: 'kibana.alert.rule.uuid',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: { id: 'string' },
shortDotsEnable: false,
isMapped: true,
indexes: ['apm-*-transaction*'],
},
'kibana.alert.workflow_status': {
category: 'kibana',
count: 0,
name: 'kibana.alert.workflow_status',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: { id: 'string' },
shortDotsEnable: false,
isMapped: true,
indexes: ['apm-*-transaction*'],
},
'kibana.alert.rule.name': {
category: 'kibana',
count: 0,
name: 'kibana.alert.rule.name',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: { id: 'string' },
shortDotsEnable: false,
isMapped: true,
indexes: ['apm-*-transaction*'],
},
},
},
},
};
const dataWithoutSeverity = props.data.filter((data) => data.field !== 'kibana.alert.severity');
const fieldsWithoutSeverity = {
'kibana.alert.risk_score': props.browserFields.kibana.fields['kibana.alert.risk_score'],
'kibana.alert.rule.uuid': props.browserFields.kibana.fields['kibana.alert.rule.uuid'],
'kibana.alert.workflow_status': props.browserFields.kibana.fields['kibana.alert.workflow_status'],
'kibana.alert.rule.name': props.browserFields.kibana.fields['kibana.alert.rule.name'],
};
const propsWithoutSeverity = {
...props,
browserFields: { kibana: { fields: fieldsWithoutSeverity } },
data: dataWithoutSeverity,
};
const propsWithReadOnly = {
...props,
isReadOnly: true,
};

View file

@ -1,226 +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 { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React, { useMemo, Fragment } from 'react';
import { chunk, find } from 'lodash/fp';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import type { EnrichedFieldInfo, EnrichedFieldInfoWithValues } from '../types';
import { getEnrichedFieldInfo } from '../helpers';
import {
ALERTS_HEADERS_RISK_SCORE,
ALERTS_HEADERS_RULE,
ALERTS_HEADERS_SEVERITY,
SIGNAL_STATUS,
} from '../../../../detections/components/alerts_table/translations';
import {
SIGNAL_RULE_NAME_FIELD_NAME,
SIGNAL_STATUS_FIELD_NAME,
} from '../../../../timelines/components/timeline/body/renderers/constants';
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
import { OverviewCardWithActions, OverviewCard } from './overview_card';
import { StatusPopoverButton } from './status_popover_button';
import { SeverityBadge } from '../../severity_badge';
import { useThrottledResizeObserver } from '../../utils';
import { getFieldFormat } from '../get_field_format';
export const NotGrowingFlexGroup = euiStyled(EuiFlexGroup)`
flex-grow: 0;
`;
interface Props {
browserFields: BrowserFields;
contextId: string;
data: TimelineEventsDetailsItem[];
eventId: string;
handleOnEventClosed: () => void;
scopeId: string;
isReadOnly?: boolean;
}
export const Overview = React.memo<Props>(
({ browserFields, contextId, data, eventId, handleOnEventClosed, scopeId, isReadOnly }) => {
const statusData = useMemo(() => {
const item = find({ field: SIGNAL_STATUS_FIELD_NAME, category: 'kibana' }, data);
return (
item &&
getEnrichedFieldInfo({
eventId,
contextId,
scopeId,
browserFields,
item,
})
);
}, [browserFields, contextId, data, eventId, scopeId]);
const severityData = useMemo(() => {
const item = find({ field: 'kibana.alert.severity', category: 'kibana' }, data);
return (
item &&
getEnrichedFieldInfo({
eventId,
contextId,
scopeId,
browserFields,
item,
})
);
}, [browserFields, contextId, data, eventId, scopeId]);
const riskScoreData = useMemo(() => {
const item = find({ field: 'kibana.alert.risk_score', category: 'kibana' }, data);
return (
item &&
getEnrichedFieldInfo({
eventId,
contextId,
scopeId,
browserFields,
item,
})
);
}, [browserFields, contextId, data, eventId, scopeId]);
const ruleNameData = useMemo(() => {
const item = find({ field: SIGNAL_RULE_NAME_FIELD_NAME, category: 'kibana' }, data);
const linkValueField = find({ field: 'kibana.alert.rule.uuid', category: 'kibana' }, data);
return (
item &&
getEnrichedFieldInfo({
eventId,
contextId,
scopeId,
browserFields,
item,
linkValueField,
})
);
}, [browserFields, contextId, data, eventId, scopeId]);
const signalCard =
hasData(statusData) && !isReadOnly ? (
<EuiFlexItem key="status">
<OverviewCardWithActions
title={SIGNAL_STATUS}
enrichedFieldInfo={statusData}
contextId={contextId}
>
<StatusPopoverButton
eventId={eventId}
contextId={contextId}
enrichedFieldInfo={statusData}
scopeId={scopeId}
handleOnEventClosed={handleOnEventClosed}
/>
</OverviewCardWithActions>
</EuiFlexItem>
) : null;
const severityCard = hasData(severityData) ? (
<EuiFlexItem key="severity">
{!isReadOnly ? (
<OverviewCardWithActions
title={ALERTS_HEADERS_SEVERITY}
enrichedFieldInfo={severityData}
contextId={contextId}
>
<SeverityBadge value={severityData.values[0] as Severity} />
</OverviewCardWithActions>
) : (
<OverviewCard title={ALERTS_HEADERS_SEVERITY}>
<SeverityBadge value={severityData.values[0] as Severity} />
</OverviewCard>
)}
</EuiFlexItem>
) : null;
const riskScoreCard = hasData(riskScoreData) ? (
<EuiFlexItem key="riskScore">
{!isReadOnly ? (
<OverviewCardWithActions
title={ALERTS_HEADERS_RISK_SCORE}
enrichedFieldInfo={riskScoreData}
contextId={contextId}
dataTestSubj="riskScore"
>
{riskScoreData.values[0]}
</OverviewCardWithActions>
) : (
<OverviewCard title={ALERTS_HEADERS_RISK_SCORE}>{riskScoreData.values[0]}</OverviewCard>
)}
</EuiFlexItem>
) : null;
const ruleNameCard =
hasData(ruleNameData) && !isReadOnly ? (
<EuiFlexItem key="ruleName">
<OverviewCardWithActions
title={ALERTS_HEADERS_RULE}
enrichedFieldInfo={ruleNameData}
contextId={contextId}
>
<FormattedFieldValue
contextId={contextId}
eventId={eventId}
value={ruleNameData.values[0]}
fieldName={ruleNameData.data.field}
linkValue={ruleNameData.linkValue}
fieldType={ruleNameData.data.type}
fieldFormat={getFieldFormat(ruleNameData.data)}
isDraggable={false}
truncate={false}
/>
</OverviewCardWithActions>
</EuiFlexItem>
) : null;
const { width, ref } = useThrottledResizeObserver();
// 675px is the container width at which none of the cards, when hovered,
// creates a visual overflow in a single row setup
const showAsSingleRow = width === 0 || (width && width >= 675);
// Only render cards with content
const cards = [signalCard, severityCard, riskScoreCard, ruleNameCard].filter(isNotNull);
// If there is enough space, render a single row.
// Otherwise, render two rows with each two cards.
const content = showAsSingleRow ? (
<NotGrowingFlexGroup gutterSize="s">{cards}</NotGrowingFlexGroup>
) : (
<>
{chunk(2, cards).map((elements, index, { length }) => {
// Add a spacer between rows but not after the last row
const addSpacer = index < length - 1;
return (
<Fragment key={index}>
<NotGrowingFlexGroup gutterSize="s">{elements}</NotGrowingFlexGroup>
{addSpacer && <EuiSpacer size="s" />}
</Fragment>
);
})}
</>
);
return <div ref={ref}>{content}</div>;
}
);
function hasData(fieldInfo?: EnrichedFieldInfo): fieldInfo is EnrichedFieldInfoWithValues {
return !!fieldInfo && Array.isArray(fieldInfo.values);
}
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
Overview.displayName = 'Overview';

View file

@ -1,99 +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 React from 'react';
import { act, render } from '@testing-library/react';
import { OverviewCardWithActions } from './overview_card';
import { createMockStore, mockGlobalState, TestProviders } from '../../../mock';
import { SeverityBadge } from '../../severity_badge';
import type { State } from '../../../store';
import { TimelineId } from '../../../../../common/types';
import { createAction } from '@kbn/ui-actions-plugin/public';
const state: State = {
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
[TimelineId.casePage]: {
...mockGlobalState.timeline.timelineById[TimelineId.test],
id: TimelineId.casePage,
},
},
},
};
const store = createMockStore(state);
const props = {
title: 'Severity',
contextId: 'timeline-case',
enrichedFieldInfo: {
contextId: 'timeline-case',
eventId: 'testid',
fieldType: 'string',
data: {
field: 'kibana.alert.rule.severity',
format: 'string',
type: 'string',
isObjectArray: false,
},
values: ['medium'],
fieldFromBrowserField: {
category: 'kibana',
count: 0,
name: 'kibana.alert.rule.severity',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: { id: 'string' },
shortDotsEnable: false,
isMapped: true,
indexes: ['apm-*-transaction*'],
description: '',
example: '',
fields: {},
},
scopeId: 'timeline-case',
},
};
jest.mock('../../../lib/kibana');
jest.mock('../../../hooks/use_get_field_spec');
const mockAction = createAction({
id: 'test_action',
execute: async () => {},
getIconType: () => 'test-icon',
getDisplayName: () => 'test-actions',
});
describe('OverviewCardWithActions', () => {
test('it renders correctly', async () => {
await act(async () => {
const { getByText, findByTestId } = render(
<TestProviders store={store} cellActions={[mockAction]}>
<OverviewCardWithActions {...props}>
<SeverityBadge value="medium" />
</OverviewCardWithActions>
</TestProviders>
);
// Headline
getByText('Severity');
// Content
getByText('Medium');
// Hover actions
await findByTestId('actionItem-test_action');
});
});
});

View file

@ -1,112 +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 { EuiFlexGroup, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
import type { FC, PropsWithChildren } from 'react';
import React from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import {
SecurityCellActions,
CellActionsMode,
SecurityCellActionsTrigger,
} from '../../cell_actions';
import type { EnrichedFieldInfo } from '../types';
import { getSourcererScopeId } from '../../../../helpers';
const ActionWrapper = euiStyled.div`
margin-left: ${({ theme }) => theme.eui.euiSizeS};
`;
const OverviewPanel = euiStyled(EuiPanel)`
&&& {
background-color: ${({ theme }) => theme.eui.euiColorLightestShade};
padding: ${({ theme }) => theme.eui.euiSizeS};
height: 78px;
}
&:hover {
.inlineActions {
opacity: 1;
width: auto;
transform: translate(0);
}
}
.inlineActions {
opacity: 0;
width: 0;
transform: translate(6px);
transition: transform 50ms ease-in-out;
&.inlineActions-popoverOpen {
opacity: 1;
width: auto;
transform: translate(0);
}
}
`;
interface OverviewCardProps {
title: string;
}
export const OverviewCard: FC<PropsWithChildren<OverviewCardProps>> = ({ title, children }) => (
<OverviewPanel borderRadius="none" hasShadow={false} hasBorder={false} paddingSize="s">
<EuiText size="s">{title}</EuiText>
<EuiSpacer size="s" />
{children}
</OverviewPanel>
);
OverviewCard.displayName = 'OverviewCard';
const ClampedContent = euiStyled.div`
/* Clamp text content to 2 lines */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
`;
ClampedContent.displayName = 'ClampedContent';
type OverviewCardWithActionsProps = OverviewCardProps & {
contextId: string;
enrichedFieldInfo: EnrichedFieldInfo;
dataTestSubj?: string;
};
export const OverviewCardWithActions: FC<PropsWithChildren<OverviewCardWithActionsProps>> = ({
title,
children,
contextId,
dataTestSubj,
enrichedFieldInfo,
}) => (
<OverviewCard title={title}>
<EuiFlexGroup alignItems="center" gutterSize="none">
<ClampedContent data-test-subj={dataTestSubj}>{children}</ClampedContent>
<ActionWrapper>
<SecurityCellActions
data={{
field: enrichedFieldInfo.data.field,
value: enrichedFieldInfo?.values,
}}
triggerId={SecurityCellActionsTrigger.DETAILS_FLYOUT}
mode={CellActionsMode.INLINE}
sourcererScopeId={getSourcererScopeId(contextId)}
metadata={{ scopeId: contextId }}
visibleCellActions={3}
/>
</ActionWrapper>
</EuiFlexGroup>
</OverviewCard>
);
OverviewCardWithActions.displayName = 'OverviewCardWithActions';

View file

@ -29,6 +29,7 @@ interface StatusPopoverButtonProps {
handleOnEventClosed: () => void;
}
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const StatusPopoverButton = React.memo<StatusPopoverButtonProps>(
({ eventId, contextId, enrichedFieldInfo, scopeId, handleOnEventClosed }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

View file

@ -58,6 +58,7 @@ const EmptyResponseActions = () => {
);
};
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const useResponseActionsView = <T extends object = JSX.Element>({
rawEventData,
ecsData,

View file

@ -1,122 +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 { render, screen } from '@testing-library/react';
import React from 'react';
import type { BrowserField } from '../../containers/source';
import { TestProviders } from '../../mock';
import type { EventFieldsData } from './types';
import { SummaryView } from './summary_view';
import { TimelineId } from '../../../../common/types';
import type { 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,
name: 'host.ip',
readFromDocValues: false,
searchable: true,
type: 'ip',
};
const hostIpData: EventFieldsData = {
...hostIpFieldFromBrowserField,
ariaRowindex: 35,
field: 'host.ip',
isObjectArray: false,
originalValue: [...hostIpValues],
values: [...hostIpValues],
};
const enrichedHostIpData: AlertSummaryRow['description'] = {
data: { ...hostIpData },
eventId,
fieldFromBrowserField: { ...hostIpFieldFromBrowserField },
isDraggable: false,
scopeId: TimelineId.test,
values: [...hostIpValues],
};
const mockCount = 90019001;
jest.mock('../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: () => ({
loading: false,
count: mockCount,
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>
);
// Shows the field name
expect(screen.getByText(hostIpData.field)).toBeInTheDocument();
// Shows all the field values
hostIpValues.forEach((ipValue) => {
expect(screen.getByText(ipValue)).toBeInTheDocument();
});
// Shows alert prevalence information
expect(screen.getByText(mockCount)).toBeInTheDocument();
// Shows the Investigate in timeline button
expect(screen.getByLabelText('Investigate in timeline')).toBeInTheDocument();
});
});
describe('when in readOnly mode', () => {
test('should only show the name and value cell', () => {
const sampleRows: AlertSummaryRow[] = [
{
title: hostIpData.field,
description: enrichedHostIpData,
},
];
render(
<TestProviders>
<SummaryView
goToTable={jest.fn()}
title="Test Summary View"
rows={sampleRows}
isReadOnly={true}
/>
</TestProviders>
);
// Does not render the prevalence and timeline items
expect(screen.queryByText(mockCount)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Investigate in timeline')).not.toBeInTheDocument();
});
});
});

View file

@ -1,103 +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 type { EuiBasicTableColumn } from '@elastic/eui';
import {
EuiLink,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
EuiIconTip,
} from '@elastic/eui';
import React from 'react';
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';
const baseColumns: 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,
},
];
const allColumns: Array<EuiBasicTableColumn<AlertSummaryRow>> = [
...baseColumns,
{
field: 'description',
truncateText: true,
render: PrevalenceCellRenderer,
name: (
<>
{i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE}{' '}
<EuiIconTip
type="iInCircle"
color="subdued"
title={i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE}
content={<span>{i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE_TOOLTIP}</span>}
/>
</>
),
align: 'right',
width: '130px',
},
];
const rowProps = {
// Class name for each row. On hover of a row, all actions for that row will be shown.
className: 'flyoutTableHoverActions',
};
const SummaryViewComponent: React.FC<{
goToTable: () => void;
title: string;
rows: AlertSummaryRow[];
isReadOnly?: boolean;
}> = ({ goToTable, rows, title, isReadOnly }) => {
const columns = isReadOnly ? baseColumns : allColumns;
return (
<div>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xxxs">
<h5>{title}</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink onClick={goToTable} data-test-subj="summary-view-go-to-table-link">
<EuiText size="xs">{VIEW_ALL_FIELDS}</EuiText>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<SummaryTable
data-test-subj="summary-view"
items={rows}
columns={columns}
rowProps={rowProps}
compressed
/>
</div>
);
};
export const SummaryView = React.memo(SummaryViewComponent);

View file

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

View file

@ -7,10 +7,6 @@
import { i18n } from '@kbn/i18n';
export const THREAT_INTEL = i18n.translate('xpack.securitySolution.alertDetails.threatIntel', {
defaultMessage: 'Threat Intel',
});
export const INVESTIGATION_GUIDE = i18n.translate(
'xpack.securitySolution.alertDetails.overview.investigationGuide',
{
@ -18,54 +14,10 @@ export const INVESTIGATION_GUIDE = i18n.translate(
}
);
export const OVERVIEW = i18n.translate('xpack.securitySolution.alertDetails.overview', {
defaultMessage: 'Overview',
});
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',
});
export const JSON_VIEW = i18n.translate('xpack.securitySolution.eventDetails.jsonView', {
defaultMessage: 'JSON',
});
export const OSQUERY_VIEW = i18n.translate('xpack.securitySolution.eventDetails.osqueryView', {
defaultMessage: 'Osquery Results',
});
@ -96,19 +48,6 @@ export const PLACEHOLDER = i18n.translate(
}
);
export const VIEW_COLUMN = (field: string) =>
i18n.translate('xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel', {
values: { field },
defaultMessage: 'View {field} column',
});
export const NESTED_COLUMN = (field: string) =>
i18n.translate('xpack.securitySolution.eventDetails.nestedColumnCheckboxAriaLabel', {
values: { field },
defaultMessage:
'The {field} field is an object, and is broken down into nested fields which can be added as column',
});
export const AGENT_STATUS = i18n.translate('xpack.securitySolution.detections.alerts.agentStatus', {
defaultMessage: 'Agent status',
});
@ -146,10 +85,6 @@ export const ALERT_REASON = i18n.translate('xpack.securitySolution.eventDetails.
defaultMessage: 'Alert reason',
});
export const VIEW_ALL_FIELDS = i18n.translate('xpack.securitySolution.eventDetails.viewAllFields', {
defaultMessage: 'View all fields in table',
});
export const ENDPOINT_COMMANDS = Object.freeze({
tried: (command: string) =>
i18n.translate('xpack.securitySolution.eventDetails.responseActions.endpoint.tried', {
@ -177,10 +112,6 @@ export const SUMMARY_VIEW = i18n.translate('xpack.securitySolution.eventDetails.
defaultMessage: 'summary',
});
export const TIMELINE_VIEW = i18n.translate('xpack.securitySolution.eventDetails.timelineView', {
defaultMessage: 'Timeline',
});
export const ALERT_SUMMARY_CONVERSATION_ID = i18n.translate(
'xpack.securitySolution.alertSummaryView.alertSummaryViewConversationId',
{

View file

@ -219,8 +219,7 @@ const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux
scopeId: tableId,
});
const { DetailsPanel, SessionView } = useSessionView({
entityType,
const { SessionView } = useSessionView({
scopeId: tableId,
});
@ -617,7 +616,6 @@ const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux
</StyledEuiPanel>
</InspectButtonContainer>
</FullScreenContainer>
{DetailsPanel}
</>
);
};

View file

@ -188,7 +188,6 @@ const timeline = {
'threat_match',
'zeek',
],
expandedDetail: {},
filters: [],
kqlQuery: { filterQuery: null },
indexNames: ['.alerts-security.alerts-default'],

View file

@ -33,6 +33,7 @@ interface UserAlertPrevalenceResult {
alertIds?: string[];
}
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const useAlertPrevalence = ({
field,
value,

View file

@ -1,102 +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 { renderHook } from '@testing-library/react-hooks';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants';
import { useAlertsByIds } from './use_alerts_by_ids';
jest.mock('../../../detections/containers/detection_engine/alerts/use_query', () => ({
useQueryAlerts: jest.fn(),
}));
const mockUseQueryAlerts = useQueryAlerts as jest.Mock;
const alertIds = ['1', '2', '3'];
const testResult = {
hits: {
hits: [{ result: 1 }, { result: 2 }],
},
};
describe('useAlertsByIds', () => {
beforeEach(() => {
mockUseQueryAlerts.mockReset();
});
it('passes down the loading state', () => {
mockUseQueryAlerts.mockReturnValue({
loading: true,
setQuery: jest.fn(),
});
const { result } = renderHook(() => useAlertsByIds({ alertIds }));
expect(result.current).toEqual({ loading: true, error: false });
});
it('calculates the error state', () => {
mockUseQueryAlerts.mockReturnValue({
loading: false,
data: undefined,
setQuery: jest.fn(),
});
const { result } = renderHook(() => useAlertsByIds({ alertIds }));
expect(result.current).toEqual({ loading: false, error: true, data: undefined });
});
it('returns the results', () => {
mockUseQueryAlerts.mockReturnValue({
loading: false,
data: testResult,
setQuery: jest.fn(),
});
const { result } = renderHook(() => useAlertsByIds({ alertIds }));
expect(result.current).toEqual({ loading: false, error: false, data: testResult.hits.hits });
});
it('constructs the correct query', () => {
mockUseQueryAlerts.mockReturnValue({
loading: true,
setQuery: jest.fn(),
});
renderHook(() => useAlertsByIds({ alertIds }));
expect(mockUseQueryAlerts).toHaveBeenCalledWith({
queryName: ALERTS_QUERY_NAMES.BY_ID,
query: expect.objectContaining({
fields: ['*'],
_source: false,
query: {
ids: {
values: alertIds,
},
},
}),
});
});
it('requests the specified fields', () => {
const testFields = ['test.*'];
mockUseQueryAlerts.mockReturnValue({
loading: true,
setQuery: jest.fn(),
});
renderHook(() => useAlertsByIds({ alertIds, fields: testFields }));
expect(mockUseQueryAlerts).toHaveBeenCalledWith({
queryName: ALERTS_QUERY_NAMES.BY_ID,
query: expect.objectContaining({ fields: testFields }),
});
});
});

View file

@ -1,72 +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 { useEffect, useState } from 'react';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants';
interface UseAlertByIdsOptions {
alertIds: string[];
fields?: string[];
}
interface Hit {
fields: Record<string, string[]>;
_index: string;
_id: string;
}
interface UserAlertByIdsResult {
loading: boolean;
error: boolean;
data?: Hit[];
}
// It prevents recreating the array on every hook call
const ALL_FIELD = ['*'];
/**
* Fetches the alert documents associated to the ids that are passed.
* By default it fetches all fields but they can be limited by passing
* the `fields` parameter.
*/
export const useAlertsByIds = ({
alertIds,
fields = ALL_FIELD,
}: UseAlertByIdsOptions): UserAlertByIdsResult => {
const [initialQuery] = useState(() => generateAlertByIdsQuery(alertIds, fields));
const { loading, data, setQuery } = useQueryAlerts<Hit, unknown>({
query: initialQuery,
queryName: ALERTS_QUERY_NAMES.BY_ID,
});
useEffect(() => {
setQuery(generateAlertByIdsQuery(alertIds, fields));
}, [setQuery, alertIds, fields]);
const error = !loading && data === undefined;
return {
loading,
error,
data: data?.hits.hits,
};
};
const generateAlertByIdsQuery = (alertIds: string[], fields: string[]) => {
return {
fields,
_source: false,
query: {
ids: {
values: alertIds,
},
},
};
};

View file

@ -27,6 +27,7 @@ export const QUERY_ID = 'investigation_time_enrichment';
const noop = () => {};
const noEnrichments = { enrichments: [] };
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const useInvestigationTimeEnrichment = (eventFields: EventFields) => {
const { addError } = useAppToasts();
const { data, uiSettings } = useKibana().services;

View file

@ -96,6 +96,7 @@ const getFieldsValue = (
export type GetFieldsDataValue = string | string[] | null | undefined;
export type GetFieldsData = (field: string) => GetFieldsDataValue;
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const useGetFieldsData = (fieldsData: SearchHit['fields'] | undefined): GetFieldsData => {
// TODO: Move cache to top level container such as redux or context. Make it store type agnostic if possible
// TODO: Handle updates where data is re-requested and the cache is reset.

View file

@ -356,7 +356,6 @@ export const mockGlobalState: State = {
},
eventIdToNoteIds: { '1': ['1'] },
excludedRowRendererIds: [],
expandedDetail: {},
highlightedDropAndProviderId: '',
historyIds: [],
isFavorite: false,
@ -410,7 +409,6 @@ export const mockGlobalState: State = {
defaultColumns: defaultHeaders,
dataViewId: 'security-solution-default',
deletedEventIds: [],
expandedDetail: {},
filters: [],
indexNames: ['.alerts-security.alerts-default'],
isSelectAllChecked: false,

View file

@ -1871,7 +1871,6 @@ export const mockTimelineModel: TimelineModel = {
eventIdToNoteIds: {},
eventType: 'all',
excludedRowRendererIds: [],
expandedDetail: {},
filters: [
{
$state: {
@ -1938,7 +1937,6 @@ export const mockDataTableModel: DataTableModel = {
defaultColumns: mockTimelineModelColumns,
dataViewId: null,
deletedEventIds: [],
expandedDetail: {},
filters: [
{
$state: {
@ -2072,7 +2070,6 @@ export const defaultTimelineProps: CreateTimelineProps = {
RowRendererId.threat_match,
RowRendererId.zeek,
],
expandedDetail: {},
filters: [],
highlightedDropAndProviderId: '',
historyIds: [],

View file

@ -20,7 +20,6 @@ const {
applyDeltaToColumnWidth,
changeViewMode,
removeColumn,
toggleDetailPanel,
updateColumnOrder,
updateColumns,
updateColumnWidth,
@ -44,7 +43,6 @@ const tableActionTypes = new Set([
updateShowBuildingBlockAlertsFilter.type,
updateTotalCount.type,
updateIsLoading.type,
toggleDetailPanel.type,
]);
export const dataTableLocalStorageMiddleware: (storage: Storage) => Middleware<{}, State> =

View file

@ -24,8 +24,6 @@ import { HeaderSection } from '../../../../common/components/header_section';
import { getAlertsPreviewDefaultModel } from '../../../../detections/components/alerts_table/default_config';
import { SourcererScopeName } from '../../../../sourcerer/store/model';
import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants';
import { useSourcererDataView } from '../../../../sourcerer/containers';
import { DetailsPanel } from '../../../../timelines/components/side_panel';
import { PreviewRenderCellValue } from './preview_table_cell_renderer';
import { getPreviewTableControlColumn } from './preview_table_control_columns';
import { useGlobalFullScreen } from '../../../../common/containers/use_full_screen';
@ -95,7 +93,6 @@ const PreviewHistogramComponent = ({
);
const license = useLicense();
const { browserFields, runtimeMappings } = useSourcererDataView(SourcererScopeName.detections);
const { globalFullScreen } = useGlobalFullScreen();
const previousPreviewId = usePrevious(previewId);
@ -202,13 +199,6 @@ const PreviewHistogramComponent = ({
bulkActions={false}
/>
</FullScreenContainer>
<DetailsPanel
browserFields={browserFields}
isFlyoutView
runtimeMappings={runtimeMappings}
scopeId={TableId.rulePreview}
isReadOnly
/>
</>
);
};

View file

@ -381,7 +381,6 @@ describe('alert actions', () => {
eventIdToNoteIds: {},
eventType: 'all',
excludedRowRendererIds: [],
expandedDetail: {},
filters: [
{
$state: {

View file

@ -331,8 +331,7 @@ export const AlertsTableComponent: FC<DetectionEngineAlertTableProps> = ({
scopeId: tableId,
});
const { DetailsPanel, SessionView } = useSessionView({
entityType: 'events',
const { SessionView } = useSessionView({
scopeId: tableId,
});
@ -356,7 +355,6 @@ export const AlertsTableComponent: FC<DetectionEngineAlertTableProps> = ({
<EuiDataGridContainer hideLastPage={false}>{AlertTable}</EuiDataGridContainer>
</StatefulEventContext.Provider>
</FullWidthFlexGroupTable>
{DetailsPanel}
</div>
);
};

View file

@ -7,17 +7,6 @@
import { i18n } from '@kbn/i18n';
export const PAGE_TITLE = i18n.translate('xpack.securitySolution.detectionEngine.pageTitle', {
defaultMessage: 'Detection engine',
});
export const ALERTS_DOCUMENT_TYPE = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.documentTypeTitle',
{
defaultMessage: 'Alerts',
}
);
export const OPEN_ALERTS = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.openAlertsTitle',
{
@ -39,13 +28,6 @@ export const ACKNOWLEDGED_ALERTS = i18n.translate(
}
);
export const LOADING_ALERTS = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.loadingAlertsTitle',
{
defaultMessage: 'Loading Alerts',
}
);
export const TOTAL_COUNT_OF_ALERTS = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.totalCountOfAlertsTitle',
{
@ -295,13 +277,6 @@ export const CLICK_TO_CHANGE_ALERT_STATUS = i18n.translate(
}
);
export const SIGNAL_STATUS = i18n.translate(
'xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle',
{
defaultMessage: 'Status',
}
);
export const TRIGGERED = i18n.translate(
'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.triggeredTitle',
{
@ -334,13 +309,6 @@ export const SESSIONS_TITLE = i18n.translate('xpack.securitySolution.sessionsVie
defaultMessage: 'Sessions',
});
export const TAKE_ACTION = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.additionalActions.takeAction',
{
defaultMessage: 'Take actions',
}
);
export const STATS_GROUP_ALERTS = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.alertsCount',
{
@ -355,20 +323,6 @@ export const STATS_GROUP_HOSTS = i18n.translate(
}
);
export const STATS_GROUP_IPS = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.ipsCount',
{
defaultMessage: `IP's:`,
}
);
export const GROUP_ALERTS_SELECTOR = i18n.translate(
'xpack.securitySolution.detectionEngine.selectGroup.title',
{
defaultMessage: `Group alerts by`,
}
);
export const STATS_GROUP_USERS = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.usersCount',
{

View file

@ -9,6 +9,7 @@ import type { Threats } from '@kbn/securitysolution-io-ts-alerting-types';
import type { SearchHit } from '../../../common/search_strategy';
import { buildThreatDescription } from '../../detection_engine/rule_creation_ui/components/description_step/helpers';
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const getMitreComponentParts = (searchHit?: SearchHit) => {
const ruleParameters = searchHit?.fields
? searchHit?.fields['kibana.alert.rule.parameters']

View file

@ -1,98 +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 { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../../common/mock';
import { ONLY_FIRST_ITEM_PAGINATION, useRiskScoreData } from './use_risk_score_data';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { RiskScoreEntity } from '../../../../common/search_strategy';
import { useRiskScore } from './use_risk_score';
jest.mock('./use_risk_score');
jest.mock('../../../timelines/components/side_panel/event_details/helpers');
const mockUseRiskScore = useRiskScore as jest.Mock;
const mockUseBasicDataFromDetailsData = useBasicDataFromDetailsData as jest.Mock;
const defaultResult = {
data: [],
inspect: {},
isInspected: false,
isAuthorized: true,
isModuleEnabled: true,
refetch: () => {},
totalCount: 0,
loading: false,
};
const defaultRisk = {
loading: false,
isModuleEnabled: true,
result: [],
};
const defaultArgs = [
{
field: 'host.name',
isObjectArray: false,
},
];
describe('useRiskScoreData', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseRiskScore.mockReturnValue(defaultResult);
mockUseBasicDataFromDetailsData.mockReturnValue({
hostName: 'host',
userName: 'user',
});
});
test('returns expected default values', () => {
const { result } = renderHook(() => useRiskScoreData(defaultArgs), {
wrapper: TestProviders,
});
expect(result.current).toEqual({
hostRisk: defaultRisk,
userRisk: defaultRisk,
isAuthorized: true,
});
});
test('builds filter query for risk score hooks', () => {
renderHook(() => useRiskScoreData(defaultArgs), {
wrapper: TestProviders,
});
expect(mockUseRiskScore).toHaveBeenCalledWith({
filterQuery: { terms: { 'user.name': ['user'] } },
pagination: ONLY_FIRST_ITEM_PAGINATION,
skip: false,
riskEntity: RiskScoreEntity.user,
});
expect(mockUseRiskScore).toHaveBeenCalledWith({
filterQuery: { terms: { 'host.name': ['host'] } },
pagination: ONLY_FIRST_ITEM_PAGINATION,
skip: false,
riskEntity: RiskScoreEntity.host,
});
});
test('skips risk score hooks with no entity name', () => {
mockUseBasicDataFromDetailsData.mockReturnValue({ hostName: undefined, userName: undefined });
renderHook(() => useRiskScoreData(defaultArgs), {
wrapper: TestProviders,
});
expect(mockUseRiskScore).toHaveBeenCalledWith({
filterQuery: undefined,
pagination: ONLY_FIRST_ITEM_PAGINATION,
skip: true,
riskEntity: RiskScoreEntity.user,
});
expect(mockUseRiskScore).toHaveBeenCalledWith({
filterQuery: undefined,
pagination: ONLY_FIRST_ITEM_PAGINATION,
skip: true,
riskEntity: RiskScoreEntity.host,
});
});
});

View file

@ -1,83 +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 { useMemo } from 'react';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import {
buildHostNamesFilter,
buildUserNamesFilter,
RiskScoreEntity,
} from '../../../../common/search_strategy';
import { useRiskScore } from './use_risk_score';
import type { HostRisk, UserRisk } from '../types';
export const ONLY_FIRST_ITEM_PAGINATION = {
cursorStart: 0,
querySize: 1,
};
export const useRiskScoreData = (data: TimelineEventsDetailsItem[]) => {
const { hostName, userName } = useBasicDataFromDetailsData(data);
const hostNameFilterQuery = useMemo(
() => (hostName ? buildHostNamesFilter([hostName]) : undefined),
[hostName]
);
const {
data: hostRiskData,
loading: hostRiskLoading,
isAuthorized: isHostRiskScoreAuthorized,
isModuleEnabled: isHostRiskModuleEnabled,
} = useRiskScore({
filterQuery: hostNameFilterQuery,
pagination: ONLY_FIRST_ITEM_PAGINATION,
riskEntity: RiskScoreEntity.host,
skip: !hostNameFilterQuery,
});
const hostRisk: HostRisk = useMemo(
() => ({
loading: hostRiskLoading,
isModuleEnabled: isHostRiskModuleEnabled,
result: hostRiskData,
}),
[hostRiskData, hostRiskLoading, isHostRiskModuleEnabled]
);
const userNameFilterQuery = useMemo(
() => (userName ? buildUserNamesFilter([userName]) : undefined),
[userName]
);
const {
data: userRiskData,
loading: userRiskLoading,
isAuthorized: isUserRiskScoreAuthorized,
isModuleEnabled: isUserRiskModuleEnabled,
} = useRiskScore({
filterQuery: userNameFilterQuery,
pagination: ONLY_FIRST_ITEM_PAGINATION,
riskEntity: RiskScoreEntity.user,
skip: !userNameFilterQuery,
});
const userRisk: UserRisk = useMemo(
() => ({
loading: userRiskLoading,
isModuleEnabled: isUserRiskModuleEnabled,
result: userRiskData,
}),
[userRiskLoading, isUserRiskModuleEnabled, userRiskData]
);
return {
userRisk,
hostRisk,
isAuthorized: isHostRiskScoreAuthorized && isUserRiskScoreAuthorized,
};
};

View file

@ -1,96 +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 React from 'react';
import { render } from '@testing-library/react';
import { TestProviders } from '../../common/mock';
import type { RiskEntity } from './risk_summary_panel';
import * as i18n from '../../common/components/event_details/cti_details/translations';
import { RiskSummaryPanel } from './risk_summary_panel';
import { RiskScoreEntity, RiskSeverity } from '../../../common/search_strategy';
import { getEmptyValue } from '../../common/components/empty_value';
describe.each([RiskScoreEntity.host, RiskScoreEntity.user])(
'RiskSummary entityType: %s',
(riskEntity) => {
it(`renders ${riskEntity} risk data`, () => {
const riskSeverity = RiskSeverity.Low;
const risk = {
loading: false,
isModuleEnabled: true,
result: [
{
'@timestamp': '1641902481',
[riskEntity === RiskScoreEntity.host ? 'host' : 'user']: {
name: 'test-host-name',
risk: {
multipliers: [],
calculated_score_norm: 9999,
calculated_level: riskSeverity,
rule_risks: [],
},
},
},
], // as unknown as HostRiskScore[] | UserRiskScore[],
} as unknown as RiskEntity['risk'];
const props = {
riskEntity,
risk,
} as RiskEntity;
const { getByText } = render(
<TestProviders>
<RiskSummaryPanel {...props} />
</TestProviders>
);
expect(getByText(riskSeverity)).toBeInTheDocument();
expect(getByText(i18n.RISK_DATA_TITLE(riskEntity))).toBeInTheDocument();
});
it('renders spinner when loading', () => {
const risk = {
loading: true,
isModuleEnabled: true,
result: [],
};
const props = {
riskEntity,
risk,
} as RiskEntity;
const { getByTestId } = render(
<TestProviders>
<RiskSummaryPanel {...props} />
</TestProviders>
);
expect(getByTestId('loading')).toBeInTheDocument();
});
it(`renders empty value when there is no ${riskEntity} data`, () => {
const risk = {
loading: false,
isModuleEnabled: true,
result: [],
};
const props = {
riskEntity,
risk,
} as RiskEntity;
const { getByText } = render(
<TestProviders>
<RiskSummaryPanel {...props} />
</TestProviders>
);
expect(getByText(getEmptyValue())).toBeInTheDocument();
});
}
);

View file

@ -1,103 +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 React from 'react';
import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from '../../common/components/event_details/cti_details/translations';
import {
EnrichedDataRow,
ThreatSummaryPanelHeader,
} from '../../common/components/event_details/cti_details/threat_summary_view';
import { RiskScoreLevel } from './severity/common';
import type { RiskSeverity } from '../../../common/search_strategy';
import { RiskScoreEntity } from '../../../common/search_strategy';
import { getEmptyValue } from '../../common/components/empty_value';
import { EntityAnalyticsLearnMoreLink } from './risk_score_onboarding/entity_analytics_doc_link';
import { RiskScoreHeaderTitle } from './risk_score_onboarding/risk_score_header_title';
import type { HostRisk, UserRisk } from '../api/types';
interface HostRiskEntity {
originalRisk?: RiskSeverity | undefined;
risk: HostRisk;
riskEntity: RiskScoreEntity.host;
}
interface UserRiskEntity {
originalRisk?: RiskSeverity | undefined;
risk: UserRisk;
riskEntity: RiskScoreEntity.user;
}
export type RiskEntity = HostRiskEntity | UserRiskEntity;
const RiskSummaryPanelComponent: React.FC<RiskEntity> = ({ risk, riskEntity, originalRisk }) => {
const currentRiskScore =
riskEntity === RiskScoreEntity.host
? risk?.result?.[0]?.host?.risk?.calculated_level
: risk?.result?.[0]?.user?.risk?.calculated_level;
return (
<>
<EuiPanel hasBorder paddingSize="s" grow={false}>
<ThreatSummaryPanelHeader
title={
<RiskScoreHeaderTitle
title={i18n.RISK_DATA_TITLE(riskEntity)}
riskScoreEntity={riskEntity}
/>
}
toolTipTitle={
<RiskScoreHeaderTitle
title={i18n.RISK_DATA_TITLE(riskEntity)}
riskScoreEntity={riskEntity}
/>
}
toolTipContent={
<FormattedMessage
id="xpack.securitySolution.alertDetails.overview.riskDataTooltipContent"
defaultMessage="Risk level is displayed only when available for a {riskEntity}. Ensure {riskScoreDocumentationLink} is enabled within your environment."
values={{
riskEntity,
riskScoreDocumentationLink: (
<EntityAnalyticsLearnMoreLink title={i18n.RISK_SCORING_TITLE} />
),
}}
/>
}
/>
{risk.loading && <EuiLoadingSpinner data-test-subj="loading" />}
{!risk.loading && (
<>
<EnrichedDataRow
field={i18n.CURRENT_RISK_LEVEL(riskEntity)}
value={
currentRiskScore ? (
<RiskScoreLevel severity={currentRiskScore} hideBackgroundColor />
) : (
getEmptyValue()
)
}
/>
{originalRisk && currentRiskScore !== originalRisk && (
<>
<EnrichedDataRow
field={i18n.ORIGINAL_RISK_LEVEL(riskEntity)}
value={<RiskScoreLevel severity={originalRisk} hideBackgroundColor />}
/>
</>
)}
</>
)}
</EuiPanel>
</>
);
};
export const RiskSummaryPanel = React.memo(RiskSummaryPanelComponent);

View file

@ -70,6 +70,7 @@ export const getColumns: ColumnsProvider = ({
/**
* Table view displayed in the document details expandable flyout right section
*/
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const TableTab = memo(() => {
const { browserFields, dataFormattedForFieldBrowser, eventId, scopeId } =
useDocumentDetailsContext();

View file

@ -313,14 +313,6 @@ export const getScopedActions = (scopeId: string) => {
}
};
export const getScopedSelectors = (scopeId: string) => {
if (isTimelineScope(scopeId)) {
return timelineActions;
} else if (isInTableScope(scopeId)) {
return dataTableActions;
}
};
export const isActiveTimeline = (timelineId: string) => timelineId === TimelineId.active;
export const getSourcererScopeId = (scopeId: string): SourcererScopeName => {

View file

@ -11,7 +11,6 @@ import userEvent from '@testing-library/user-event';
import { FormattedIp } from '.';
import { TestProviders } from '../../../common/mock';
import { TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import { timelineActions } from '../../store';
import { StatefulEventContext } from '../../../common/components/events_viewer/stateful_event_context';
import { NetworkPanelKey } from '../../../flyout/network_details';
@ -44,16 +43,7 @@ jest.mock('../../../common/components/drag_and_drop/draggable_wrapper', () => {
};
});
jest.mock('../../store', () => {
const original = jest.requireActual('../../store');
return {
...original,
timelineActions: {
...original.timelineActions,
toggleDetailPanel: jest.fn(),
},
};
});
jest.mock('../../store');
const mockOpenFlyout = jest.fn();
jest.mock('@kbn/expandable-flyout', () => ({
@ -97,17 +87,6 @@ describe('FormattedIp', () => {
expect(screen.getByTestId('DraggableWrapper')).toBeInTheDocument();
});
test('if not enableIpDetailsFlyout, should go to network details page', () => {
render(
<TestProviders>
<FormattedIp {...props} />
</TestProviders>
);
userEvent.click(screen.getByTestId('network-details'));
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
});
test('if enableIpDetailsFlyout, should open NetworkDetails expandable flyout', () => {
const context = {
enableHostDetailsFlyout: true,

View file

@ -1,286 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Details Panel Component DetailsPanel: rendering it should not render the DetailsPanel if an expanded detail with a panelView, but not params have been set:
<DetailsPanel
browserFields={Object {}}
handleOnPanelClosed={[MockFunction]}
isFlyoutView={false}
runtimeMappings={Object {}}
tabType="query"
scopeId="timeline-test"
/>
1`] = `
<DetailsPanel
browserFields={Object {}}
handleOnPanelClosed={[MockFunction]}
isFlyoutView={false}
runtimeMappings={Object {}}
scopeId="timeline-test"
tabType="query"
/>
`;
exports[`Details Panel Component DetailsPanel: rendering it should not render the DetailsPanel if no expanded detail has been set in the reducer 1`] = `
<DetailsPanel
browserFields={Object {}}
handleOnPanelClosed={[MockFunction]}
isFlyoutView={false}
runtimeMappings={Object {}}
scopeId="timeline-test"
tabType="query"
/>
`;
exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details Panel when the panelView is set and the associated params are set 1`] = `
Array [
.c0 {
-webkit-flex: 0 1 auto;
-ms-flex: 0 1 auto;
flex: 0 1 auto;
margin-top: 8px;
}
<div
class="euiFlexGroup c0 emotion-euiFlexGroup-responsive-wrap-none-spaceBetween-stretch-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
/>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexEnd-column"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<button
aria-label="close"
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-primary"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="cross"
/>
</button>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
/>
</div>
</div>
</div>
</div>,
<div
class="euiSpacer euiSpacer--m emotion-euiSpacer-m"
/>,
<div
aria-busy="true"
data-test-subj="euiSkeletonLoadingAriaWrapper"
>
<span
aria-label="Loading "
class="euiSkeletonText"
role="progressbar"
>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
</span>
</div>,
.c0 .side-panel-flyout-footer {
background-color: transparent;
}
<div
class="euiPanel euiPanel--plain euiPanel--paddingMedium c0 emotion-euiPanel-grow-none-m-plain"
>
<div
class="euiFlyoutFooter side-panel-flyout-footer emotion-euiFlyoutFooter"
data-test-subj="side-panel-flyout-footer"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
/>
</div>
</div>
</div>,
]
`;
exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set 1`] = `
.c0 {
-webkit-flex: 0 1 auto;
-ms-flex: 0 1 auto;
flex: 0 1 auto;
margin-top: 8px;
}
.c1 .euiFlyoutBody__overflow {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
overflow: hidden;
}
.c1 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent {
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
overflow: hidden;
padding: 0 12px 12px;
}
.c2 .side-panel-flyout-footer {
background-color: transparent;
}
<div
data-eui="EuiFlyout"
data-test-subj="timeline:details-panel:flyout"
role="dialog"
>
<button
aria-label="Close this dialog"
data-test-subj="euiFlyoutCloseButton"
type="button"
/>
<div
class="euiFlyoutHeader emotion-euiFlyoutHeader"
>
<div
class="euiFlexGroup c0 emotion-euiFlexGroup-responsive-wrap-none-spaceBetween-stretch-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
/>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexEnd-column"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="euiFlyoutBody c1 emotion-euiFlyoutBody"
>
<div
class="euiFlyoutBody__overflow emotion-euiFlyoutBody__overflow-noBanner"
tabindex="0"
>
<div
class="euiFlyoutBody__overflowContent"
>
<div
aria-busy="true"
data-test-subj="euiSkeletonLoadingAriaWrapper"
>
<span
aria-label="Loading "
class="euiSkeletonText"
role="progressbar"
>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
<span
class="emotion-euiSkeletonGradientAnimation-euiSkeletonText-m"
/>
</span>
</div>
</div>
</div>
</div>
<div
class="euiPanel euiPanel--plain euiPanel--paddingMedium c2 emotion-euiPanel-grow-none-m-plain"
>
<div
class="euiFlyoutFooter side-panel-flyout-footer emotion-euiFlyoutFooter"
data-test-subj="side-panel-flyout-footer"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
/>
</div>
</div>
</div>
</div>
`;

View file

@ -1,248 +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 { NewChatByTitle } from '@kbn/elastic-assistant';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { isEmpty } from 'lodash/fp';
import {
EuiButtonIcon,
EuiButtonEmpty,
EuiTextColor,
EuiSkeletonText,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiCopy,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import { TableId } from '@kbn/securitysolution-data-table';
import { DocumentDetailsRightPanelKey } from '../../../../flyout/document_details/shared/constants/panel_keys';
import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state';
import type { GetFieldsData } from '../../../../common/hooks/use_get_fields_data';
import { Assignees } from '../../../../flyout/document_details/right/components/assignees';
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
import type { TimelineTabs } from '../../../../../common/types/timeline';
import type { BrowserFields } from '../../../../common/containers/source';
import { EventDetails } from '../../../../common/components/event_details/event_details';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import * as i18n from './translations';
import {
ALERT_SUMMARY_CONVERSATION_ID,
EVENT_SUMMARY_CONVERSATION_ID,
} from '../../../../common/components/event_details/translations';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link';
import { useRefetchByScope } from './flyout/use_refetch_by_scope';
export type HandleOnEventClosed = () => void;
interface Props {
browserFields: BrowserFields;
detailsData: TimelineEventsDetailsItem[] | null;
detailsEcsData: Ecs | null;
event: { eventId: string; indexName: string };
isAlert: boolean;
isDraggable?: boolean;
loading: boolean;
messageHeight?: number;
rawEventData: object | undefined;
timelineTabType: TimelineTabs | 'flyout';
scopeId: string;
handleOnEventClosed: HandleOnEventClosed;
isReadOnly?: boolean;
}
interface ExpandableEventTitleProps {
eventId: string;
eventIndex: string;
isAlert: boolean;
loading: boolean;
promptContextId?: string;
ruleName?: string;
timestamp: string;
handleOnEventClosed?: HandleOnEventClosed;
scopeId: string;
refetchFlyoutData: () => Promise<void>;
getFieldsData: GetFieldsData;
}
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
flex: 0 1 auto;
${({ theme }) => `margin-top: ${theme.eui.euiSizeS};`}
`;
const StyledFlexGroup = styled(EuiFlexGroup)`
height: 100%;
`;
const StyledEuiFlexItem = styled(EuiFlexItem)`
&.euiFlexItem {
flex: 1 0 0;
overflow: hidden;
}
`;
export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
({
eventId,
eventIndex,
isAlert,
loading,
handleOnEventClosed,
promptContextId,
ruleName,
timestamp,
scopeId,
refetchFlyoutData,
getFieldsData,
}) => {
const { hasAssistantPrivilege } = useAssistantAvailability();
const alertDetailsLink = useGetAlertDetailsFlyoutLink({
_id: eventId,
_index: eventIndex,
timestamp,
});
const urlModifier = (value: string) => {
// this is actually only needed for when users click on the Share Alert button and then enable the expandable flyout
// (for the old (non-expandable) flyout, we do not need to save anything in the url as we automatically open the flyout here: x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx
return `${value}&${URL_PARAM_KEY.flyout}=(preview:!(),right:(id:${DocumentDetailsRightPanelKey},params:(id:'${eventId}',indexName:${eventIndex},scopeId:${scopeId})))`;
};
const { refetch } = useRefetchByScope({ scopeId });
const alertAssignees = useMemo(
() => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [],
[getFieldsData]
);
const onAssigneesUpdated = useCallback(() => {
refetch();
refetchFlyoutData();
}, [refetch, refetchFlyoutData]);
return (
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
<EuiFlexItem grow={false}>
{!loading && (
<>
<EuiTitle size="s">
<h4>{isAlert && !isEmpty(ruleName) ? ruleName : i18n.EVENT_DETAILS}</h4>
</EuiTitle>
{timestamp && (
<>
<EuiSpacer size="s" />
<PreferenceFormattedDate value={new Date(timestamp)} />
</>
)}
</>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" alignItems="flexEnd" gutterSize="none">
{handleOnEventClosed && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
aria-label={i18n.CLOSE}
onClick={handleOnEventClosed}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" direction="row" gutterSize="none">
{hasAssistantPrivilege && promptContextId != null && (
<EuiFlexItem grow={false}>
<NewChatByTitle
conversationTitle={
isAlert ? ALERT_SUMMARY_CONVERSATION_ID : EVENT_SUMMARY_CONVERSATION_ID
}
promptContextId={promptContextId}
/>
</EuiFlexItem>
)}
{isAlert && alertDetailsLink && (
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={urlModifier(alertDetailsLink)}>
{(copy) => (
<EuiButtonEmpty
onClick={copy}
iconType="share"
data-test-subj="copy-alert-flyout-link"
>
{i18n.SHARE_ALERT}
</EuiButtonEmpty>
)}
</EuiCopy>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{isAlert && scopeId !== TableId.rulePreview && (
<EuiFlexItem grow={false}>
<Assignees
eventId={eventId}
assignedUserIds={alertAssignees}
onAssigneesUpdated={onAssigneesUpdated}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</StyledEuiFlexGroup>
);
}
);
ExpandableEventTitle.displayName = 'ExpandableEventTitle';
export const ExpandableEvent = React.memo<Props>(
({
browserFields,
event,
scopeId,
timelineTabType,
isAlert,
isDraggable,
loading,
detailsData,
detailsEcsData,
rawEventData,
handleOnEventClosed,
isReadOnly,
}) => {
if (!event.eventId) {
return <EuiTextColor color="subdued">{i18n.EVENT_DETAILS_PLACEHOLDER}</EuiTextColor>;
}
if (loading) {
return <EuiSkeletonText lines={10} />;
}
return (
<StyledFlexGroup direction="column" gutterSize="none">
<StyledEuiFlexItem grow={true}>
<EventDetails
browserFields={browserFields}
data={detailsData ?? []}
detailsEcsData={detailsEcsData}
id={event.eventId}
isAlert={isAlert}
isDraggable={isDraggable}
rawEventData={rawEventData}
scopeId={scopeId}
timelineTabType={timelineTabType}
handleOnEventClosed={handleOnEventClosed}
isReadOnly={isReadOnly}
/>
</StyledEuiFlexItem>
</StyledFlexGroup>
);
}
);
ExpandableEvent.displayName = 'ExpandableEvent';

View file

@ -1,63 +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 {
EuiBetaBadge,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import {
ISOLATE_HOST,
UNISOLATE_HOST,
} from '../../../../../common/components/endpoint/host_isolation';
import { ALERT_DETAILS, TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_DESCRIPTION } from '../translations';
const BackToAlertDetailsLinkComponent = ({
showAlertDetails,
showExperimentalBadge,
isolateAction,
}: {
showAlertDetails: () => void;
showExperimentalBadge?: boolean;
isolateAction: 'isolateHost' | 'unisolateHost';
}) => (
<>
<EuiButtonEmpty iconType="arrowLeft" iconSide="left" flush="left" onClick={showAlertDetails}>
<EuiText size="xs">
<p>{ALERT_DETAILS}</p>
</EuiText>
</EuiButtonEmpty>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>{isolateAction === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{showExperimentalBadge && (
<EuiBetaBadge
css={css`
display: inline-flex;
`}
label={TECHNICAL_PREVIEW}
size="s"
tooltipContent={TECHNICAL_PREVIEW_DESCRIPTION}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
);
const BackToAlertDetailsLink = React.memo(BackToAlertDetailsLinkComponent);
export { BackToAlertDetailsLink };

View file

@ -1,116 +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 { EuiFlyoutBody } from '@elastic/eui';
import styled from 'styled-components';
import React from 'react';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import {
HostIsolationPanel,
EndpointIsolateSuccess,
} from '../../../../../common/components/endpoint/host_isolation';
import type {
BrowserFields,
TimelineEventsDetailsItem,
} from '../../../../../../common/search_strategy';
import type { HandleOnEventClosed } from '../expandable_event';
import { ExpandableEvent } from '../expandable_event';
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflow {
display: flex;
flex: 1;
overflow: hidden;
.euiFlyoutBody__overflowContent {
flex: 1;
overflow: hidden;
padding: ${({ theme }) => `0 ${theme.eui.euiSizeM} ${theme.eui.euiSizeM}`};
}
}
`;
interface FlyoutBodyComponentProps {
alertId: string;
browserFields: BrowserFields;
detailsData: TimelineEventsDetailsItem[] | null;
detailsEcsData: Ecs | null;
event: { eventId: string; indexName: string };
handleIsolationActionSuccess: () => void;
handleOnEventClosed: HandleOnEventClosed;
hostName: string;
isAlert: boolean;
isDraggable?: boolean;
isReadOnly?: boolean;
isolateAction: 'isolateHost' | 'unisolateHost';
isIsolateActionSuccessBannerVisible: boolean;
isHostIsolationPanelOpen: boolean;
loading: boolean;
rawEventData: object | undefined;
showAlertDetails: () => void;
scopeId: string;
}
const FlyoutBodyComponent = ({
alertId,
browserFields,
detailsData,
detailsEcsData,
event,
handleIsolationActionSuccess,
handleOnEventClosed,
hostName,
isAlert,
isDraggable,
isReadOnly,
isolateAction,
isHostIsolationPanelOpen,
isIsolateActionSuccessBannerVisible,
loading,
rawEventData,
showAlertDetails,
scopeId,
}: FlyoutBodyComponentProps) => {
return (
<StyledEuiFlyoutBody>
{isIsolateActionSuccessBannerVisible && (
<EndpointIsolateSuccess
hostName={hostName}
alertId={alertId}
isolateAction={isolateAction}
/>
)}
{isHostIsolationPanelOpen ? (
<HostIsolationPanel
details={detailsData}
cancelCallback={showAlertDetails}
successCallback={handleIsolationActionSuccess}
isolateAction={isolateAction}
/>
) : (
<ExpandableEvent
browserFields={browserFields}
detailsData={detailsData}
detailsEcsData={detailsEcsData}
event={event}
isAlert={isAlert}
isDraggable={isDraggable}
loading={loading}
rawEventData={rawEventData}
scopeId={scopeId}
timelineTabType="flyout"
handleOnEventClosed={handleOnEventClosed}
isReadOnly={isReadOnly}
/>
)}
</StyledEuiFlyoutBody>
);
};
const FlyoutBody = React.memo(FlyoutBodyComponent);
export { FlyoutBody };

View file

@ -41,6 +41,7 @@ interface AddExceptionModalWrapperData {
ruleName: string;
}
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const FlyoutFooterComponent = ({
detailsData,
detailsEcsData,

View file

@ -1,117 +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 { EuiFlyoutHeader } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { GetFieldsData } from '../../../../../common/hooks/use_get_fields_data';
import { ExpandableEventTitle } from '../expandable_event';
import { BackToAlertDetailsLink } from './back_to_alert_details_link';
import { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../../../common/endpoint/service/response_actions/constants';
interface FlyoutHeaderComponentProps {
eventId: string;
eventIndex: string;
isAlert: boolean;
isHostIsolationPanelOpen: boolean;
isolateAction: 'isolateHost' | 'unisolateHost';
loading: boolean;
promptContextId?: string;
ruleName: string;
showAlertDetails: () => void;
timestamp: string;
scopeId: string;
refetchFlyoutData: () => Promise<void>;
getFieldsData: GetFieldsData;
}
const FlyoutHeaderContentComponent = ({
eventId,
eventIndex,
isAlert,
isHostIsolationPanelOpen,
isolateAction,
loading,
promptContextId,
ruleName,
showAlertDetails,
timestamp,
scopeId,
refetchFlyoutData,
getFieldsData,
}: FlyoutHeaderComponentProps) => {
const isSentinelOneAlert = useMemo(
() => !!(isAlert && getFieldsData(RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one)?.length),
[getFieldsData, isAlert]
);
return (
<>
{isHostIsolationPanelOpen ? (
<BackToAlertDetailsLink
isolateAction={isolateAction}
showAlertDetails={showAlertDetails}
showExperimentalBadge={isSentinelOneAlert}
/>
) : (
<ExpandableEventTitle
eventId={eventId}
eventIndex={eventIndex}
isAlert={isAlert}
loading={loading}
promptContextId={promptContextId}
ruleName={ruleName}
timestamp={timestamp}
scopeId={scopeId}
refetchFlyoutData={refetchFlyoutData}
getFieldsData={getFieldsData}
/>
)}
</>
);
};
const FlyoutHeaderContent = React.memo(FlyoutHeaderContentComponent);
const FlyoutHeaderComponent = ({
eventId,
eventIndex,
isAlert,
isHostIsolationPanelOpen,
isolateAction,
loading,
promptContextId,
ruleName,
showAlertDetails,
timestamp,
scopeId,
refetchFlyoutData,
getFieldsData,
}: FlyoutHeaderComponentProps) => {
return (
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
<FlyoutHeaderContentComponent
eventId={eventId}
eventIndex={eventIndex}
isAlert={isAlert}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
isolateAction={isolateAction}
loading={loading}
promptContextId={promptContextId}
ruleName={ruleName}
showAlertDetails={showAlertDetails}
timestamp={timestamp}
scopeId={scopeId}
refetchFlyoutData={refetchFlyoutData}
getFieldsData={getFieldsData}
/>
</EuiFlyoutHeader>
);
};
const FlyoutHeader = React.memo(FlyoutHeaderComponent);
export { FlyoutHeader, FlyoutHeaderContent };

View file

@ -5,6 +5,5 @@
* 2.0.
*/
export { FlyoutBody } from './body';
export { FlyoutHeader } from './header';
// TODO: DELETE FIND WHEN FOOTER IS MOVED TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export { FlyoutFooter } from './footer';

View file

@ -21,6 +21,7 @@ export interface UseRefetchScopeQueryParams {
/**
* Hook to refetch data within specified scope
*/
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const useRefetchByScope = ({ scopeId }: UseRefetchScopeQueryParams) => {
const getGlobalQueries = useMemo(() => inputsSelectors.globalQuery(), []);
const getTimelineQuery = useMemo(() => inputsSelectors.timelineQueryByIdSelector(), []);

View file

@ -1,275 +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 React from 'react';
import { render } from '@testing-library/react';
import { EventDetailsPanel } from '.';
import { TestProviders } from '../../../../common/mock';
import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { KibanaServices, useKibana } from '../../../../common/lib/kibana';
import { mockBrowserFields, mockRuntimeMappings } from '../../../../common/containers/source/mock';
import { coreMock } from '@kbn/core/public/mocks';
import { mockCasesContext } from '@kbn/cases-plugin/public/mocks/mock_cases_context';
import { useTimelineEventsDetails } from '../../../containers/details';
import { allCasesPermissions } from '../../../../cases_test_utils';
import {
DEFAULT_ALERTS_INDEX,
DEFAULT_PREVIEW_INDEX,
ASSISTANT_FEATURE_ID,
} from '../../../../../common/constants';
import { useUpsellingMessage } from '../../../../common/hooks/use_upselling';
const ecsData: Ecs = {
_id: '1',
agent: { type: ['blah'] },
kibana: {
alert: {
workflow_status: ['open'],
rule: {
parameters: {},
uuid: ['testId'],
},
},
},
};
const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' });
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useLocation: () => mockUseLocation(),
};
});
jest.mock('../../../../common/hooks/use_space_id', () => ({
useSpaceId: jest.fn().mockReturnValue('testSpace'),
}));
jest.mock(
'../../../../common/components/endpoint/host_isolation/from_alerts/use_host_isolation_status',
() => {
return {
useEndpointHostIsolationStatus: jest.fn().mockReturnValue({
loading: false,
isIsolated: false,
agentStatus: 'healthy',
}),
};
}
);
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles', () => {
return {
useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../../common/components/user_profiles/use_suggest_users', () => {
return {
useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
jest.mock('../../../../detections/components/user_info', () => ({
useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]),
}));
jest.mock('../../../../common/lib/kibana');
jest.mock(
'../../../../detections/containers/detection_engine/alerts/use_alerts_privileges',
() => ({
useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }),
})
);
jest.mock('../../../../cases/components/use_insert_timeline');
jest.mock(
'../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline',
() => {
return {
useInvestigateInTimeline: jest.fn().mockReturnValue({
investigateInTimelineActionItems: [<div />],
investigateInTimelineAlertClick: () => {},
}),
};
}
);
jest.mock('../../../../detections/components/alerts_table/actions');
jest.mock('../../../../entity_analytics/api/hooks/use_risk_score', () => {
return {
useRiskScore: jest.fn().mockReturnValue({
loading: true,
data: undefined,
isModuleEnabled: false,
}),
};
});
jest.mock('../../../../common/hooks/use_upselling');
const defaultProps = {
scopeId: TimelineId.test,
isHostIsolationPanelOpen: false,
handleOnEventClosed: jest.fn(),
onAddIsolationStatusClick: jest.fn(),
expandedEvent: { eventId: ecsData._id, indexName: '' },
tabType: TimelineTabs.query,
browserFields: mockBrowserFields,
runtimeMappings: mockRuntimeMappings,
};
jest.mock('../../../containers/details', () => {
const actual = jest.requireActual('../../../containers/details');
return {
...actual,
useTimelineEventsDetails: jest.fn().mockImplementation(() => []),
};
});
describe('event details panel component', () => {
beforeEach(() => {
const coreStartMock = coreMock.createStart();
(KibanaServices.get as jest.Mock).mockReturnValue(coreStartMock);
(useKibana as jest.Mock).mockReturnValue({
services: {
application: {
capabilities: {
[ASSISTANT_FEATURE_ID]: {
'ai-assistant': true,
},
},
},
uiSettings: {
get: jest.fn().mockReturnValue([]),
},
cases: {
ui: {
getCasesContext: () => mockCasesContext,
},
cases: {
helpers: { canUseCases: jest.fn().mockReturnValue(allCasesPermissions()) },
},
},
timelines: {
getHoverActions: jest.fn().mockReturnValue({
getAddToTimelineButton: jest.fn(),
}),
},
osquery: {
OsqueryResult: jest.fn().mockReturnValue(null),
fetchAllLiveQueries: jest.fn().mockReturnValue({ data: { data: { items: [] } } }),
},
},
});
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
});
afterEach(() => {
jest.clearAllMocks();
});
test('it renders the take action dropdown in the timeline version', () => {
const wrapper = render(
<TestProviders>
<EventDetailsPanel {...defaultProps} />
</TestProviders>
);
expect(wrapper.getByTestId('side-panel-flyout-footer')).toBeTruthy();
});
test('it renders the take action dropdown in the flyout version', () => {
const wrapper = render(
<TestProviders>
<EventDetailsPanel {...defaultProps} isFlyoutView={true} />
</TestProviders>
);
expect(wrapper.getByTestId('side-panel-flyout-footer')).toBeTruthy();
});
test("it doesn't render the take action dropdown when readOnly prop is passed", () => {
const wrapper = render(
<TestProviders>
<EventDetailsPanel {...{ ...defaultProps, isReadOnly: true }} isFlyoutView={true} />
</TestProviders>
);
const element = wrapper.queryByTestId('side-panel-flyout-footer');
expect(element).toBeNull();
});
describe('Alerts', () => {
const propsWithAlertIndex = {
...defaultProps,
expandedEvent: {
eventId: ecsData._id,
indexName: `.internal${DEFAULT_ALERTS_INDEX}-testSpace`,
},
};
test('it uses the alias alerts index', () => {
render(
<TestProviders>
<EventDetailsPanel {...{ ...propsWithAlertIndex }} />
</TestProviders>
);
expect(useTimelineEventsDetails).toHaveBeenCalledWith({
entityType: 'events',
indexName: `${DEFAULT_ALERTS_INDEX}-testSpace`,
eventId: propsWithAlertIndex.expandedEvent.eventId ?? '',
runtimeMappings: mockRuntimeMappings,
skip: false,
});
});
test('it uses the alias alerts preview index', () => {
const alertPreviewProps = {
...propsWithAlertIndex,
expandedEvent: {
...propsWithAlertIndex.expandedEvent,
indexName: `.internal${DEFAULT_PREVIEW_INDEX}-testSpace`,
},
};
render(
<TestProviders>
<EventDetailsPanel {...{ ...alertPreviewProps }} />
</TestProviders>
);
expect(useTimelineEventsDetails).toHaveBeenCalledWith({
entityType: 'events',
indexName: `${DEFAULT_PREVIEW_INDEX}-testSpace`,
eventId: propsWithAlertIndex.expandedEvent.eventId,
runtimeMappings: mockRuntimeMappings,
skip: false,
});
});
test(`it does NOT use the alerts alias when regular events happen to include a trailing '${DEFAULT_ALERTS_INDEX}' in the index name`, () => {
const indexName = `.ds-logs-endpoint.alerts-default-2022.08.09-000001${DEFAULT_ALERTS_INDEX}`; // a regular event, that happens to include a trailing `.alerts-security.alerts`
const propsWithEventIndex = {
...defaultProps,
expandedEvent: {
eventId: ecsData._id,
indexName,
},
};
render(
<TestProviders>
<EventDetailsPanel {...{ ...propsWithEventIndex }} />
</TestProviders>
);
expect(useTimelineEventsDetails).toHaveBeenCalledWith({
entityType: 'events',
indexName, // <-- use the original index name, not the alerts alias
eventId: propsWithEventIndex.expandedEvent.eventId,
runtimeMappings: mockRuntimeMappings,
skip: false,
});
});
});
});

View file

@ -1,302 +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 { useAssistantOverlay } from '@kbn/elastic-assistant';
import { EuiSpacer, EuiFlyoutBody, EuiPanel } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import type { EntityType } from '@kbn/timelines-plugin/common';
import { useGetFieldsData } from '../../../../common/hooks/use_get_fields_data';
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
import { getRawData } from '../../../../assistant/helpers';
import type { BrowserFields } from '../../../../common/containers/source';
import { ExpandableEvent, ExpandableEventTitle } from './expandable_event';
import { useTimelineEventsDetails } from '../../../containers/details';
import type { TimelineTabs } from '../../../../../common/types/timeline';
import type { RunTimeMappings } from '../../../../sourcerer/store/model';
import { useHostIsolationTools } from './use_host_isolation_tools';
import { FlyoutBody, FlyoutHeader, FlyoutFooter } from './flyout';
import { useBasicDataFromDetailsData, getAlertIndexAlias } from './helpers';
import { useSpaceId } from '../../../../common/hooks/use_space_id';
import {
EndpointIsolateSuccess,
HostIsolationPanel,
} from '../../../../common/components/endpoint/host_isolation';
import {
ALERT_SUMMARY_CONVERSATION_ID,
ALERT_SUMMARY_CONTEXT_DESCRIPTION,
ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP,
EVENT_SUMMARY_CONVERSATION_ID,
EVENT_SUMMARY_CONTEXT_DESCRIPTION,
EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP,
SUMMARY_VIEW,
TIMELINE_VIEW,
} from '../../../../common/components/event_details/translations';
import {
PROMPT_CONTEXT_ALERT_CATEGORY,
PROMPT_CONTEXT_EVENT_CATEGORY,
PROMPT_CONTEXTS,
} from '../../../../assistant/content/prompt_contexts';
const FlyoutFooterContainerPanel = styled(EuiPanel)`
.side-panel-flyout-footer {
background-color: transparent;
}
`;
interface EventDetailsPanelProps {
browserFields: BrowserFields;
entityType?: EntityType;
expandedEvent: {
eventId: string;
indexName: string;
refetch?: () => void;
};
handleOnEventClosed: () => void;
isDraggable?: boolean;
isFlyoutView?: boolean;
runtimeMappings: RunTimeMappings;
tabType: TimelineTabs;
scopeId: string;
isReadOnly?: boolean;
}
const useAssistantNoop = () => ({ promptContextId: undefined });
const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
browserFields,
entityType = 'events', // Default to events so only alerts have to pass entityType in
expandedEvent,
handleOnEventClosed,
isDraggable,
isFlyoutView,
runtimeMappings,
tabType,
scopeId,
isReadOnly,
}) => {
const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability();
// TODO: changing feature flags requires a hard refresh to take effect, but this temporary workaround technically violates the rules of hooks:
const useAssistant = hasAssistantPrivilege ? useAssistantOverlay : useAssistantNoop;
const currentSpaceId = useSpaceId();
const { indexName } = expandedEvent;
const eventIndex = getAlertIndexAlias(indexName, currentSpaceId) ?? indexName;
const [loading, detailsData, rawEventData, ecsData, refetchFlyoutData] = useTimelineEventsDetails(
{
entityType,
indexName: eventIndex ?? '',
eventId: expandedEvent.eventId ?? '',
runtimeMappings,
skip: !expandedEvent.eventId,
}
);
const getFieldsData = useGetFieldsData(rawEventData?.fields);
const {
isolateAction,
isHostIsolationPanelOpen,
isIsolateActionSuccessBannerVisible,
handleIsolationActionSuccess,
showAlertDetails,
showHostIsolationPanel,
} = useHostIsolationTools();
const { alertId, isAlert, hostName, ruleName, timestamp } =
useBasicDataFromDetailsData(detailsData);
const view = useMemo(() => (isFlyoutView ? SUMMARY_VIEW : TIMELINE_VIEW), [isFlyoutView]);
const getPromptContext = useCallback(async () => getRawData(detailsData ?? []), [detailsData]);
const { promptContextId } = useAssistant(
isAlert ? 'alert' : 'event',
isAlert ? ALERT_SUMMARY_CONVERSATION_ID : EVENT_SUMMARY_CONVERSATION_ID,
isAlert ? ALERT_SUMMARY_CONTEXT_DESCRIPTION(view) : EVENT_SUMMARY_CONTEXT_DESCRIPTION(view),
getPromptContext,
null,
isAlert
? PROMPT_CONTEXTS[PROMPT_CONTEXT_ALERT_CATEGORY].suggestedUserPrompt
: PROMPT_CONTEXTS[PROMPT_CONTEXT_EVENT_CATEGORY].suggestedUserPrompt,
isAlert ? ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP : EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP,
isAssistantEnabled
);
const header = useMemo(
() =>
isFlyoutView || isHostIsolationPanelOpen ? (
<FlyoutHeader
eventId={expandedEvent.eventId}
eventIndex={eventIndex}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
isAlert={isAlert}
isolateAction={isolateAction}
loading={loading}
ruleName={ruleName}
showAlertDetails={showAlertDetails}
timestamp={timestamp}
promptContextId={promptContextId}
scopeId={scopeId}
refetchFlyoutData={refetchFlyoutData}
getFieldsData={getFieldsData}
/>
) : (
<ExpandableEventTitle
eventId={expandedEvent.eventId}
eventIndex={eventIndex}
isAlert={isAlert}
loading={loading}
ruleName={ruleName}
timestamp={timestamp}
handleOnEventClosed={handleOnEventClosed}
promptContextId={promptContextId}
scopeId={scopeId}
refetchFlyoutData={refetchFlyoutData}
getFieldsData={getFieldsData}
/>
),
[
isFlyoutView,
isHostIsolationPanelOpen,
expandedEvent.eventId,
eventIndex,
isAlert,
isolateAction,
loading,
ruleName,
showAlertDetails,
timestamp,
promptContextId,
handleOnEventClosed,
scopeId,
refetchFlyoutData,
getFieldsData,
]
);
const body = useMemo(() => {
if (isFlyoutView) {
return (
<FlyoutBody
alertId={alertId}
browserFields={browserFields}
detailsData={detailsData}
detailsEcsData={ecsData}
event={expandedEvent}
hostName={hostName}
handleIsolationActionSuccess={handleIsolationActionSuccess}
handleOnEventClosed={handleOnEventClosed}
isAlert={isAlert}
isDraggable={isDraggable}
isolateAction={isolateAction}
isIsolateActionSuccessBannerVisible={isIsolateActionSuccessBannerVisible}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loading={loading}
rawEventData={rawEventData}
showAlertDetails={showAlertDetails}
scopeId={scopeId}
isReadOnly={isReadOnly}
/>
);
} else if (isHostIsolationPanelOpen) {
return (
<>
{isIsolateActionSuccessBannerVisible && (
<EndpointIsolateSuccess
hostName={hostName}
alertId={alertId}
isolateAction={isolateAction}
/>
)}
<EuiFlyoutBody>
<HostIsolationPanel
details={detailsData}
cancelCallback={showAlertDetails}
successCallback={handleIsolationActionSuccess}
isolateAction={isolateAction}
/>
</EuiFlyoutBody>
</>
);
} else {
return (
<>
<EuiSpacer size="m" />
<ExpandableEvent
browserFields={browserFields}
detailsData={detailsData}
detailsEcsData={ecsData}
event={expandedEvent}
isAlert={isAlert}
isDraggable={isDraggable}
loading={loading}
rawEventData={rawEventData}
scopeId={scopeId}
timelineTabType={tabType}
handleOnEventClosed={handleOnEventClosed}
/>
</>
);
}
}, [
alertId,
browserFields,
detailsData,
ecsData,
expandedEvent,
handleIsolationActionSuccess,
handleOnEventClosed,
hostName,
isAlert,
isDraggable,
isFlyoutView,
isHostIsolationPanelOpen,
isIsolateActionSuccessBannerVisible,
isReadOnly,
isolateAction,
loading,
rawEventData,
showAlertDetails,
tabType,
scopeId,
]);
if (!expandedEvent?.eventId) {
return null;
}
return (
<>
{header}
{body}
<FlyoutFooterContainerPanel hasShadow={false} borderRadius="none">
<FlyoutFooter
detailsData={detailsData}
detailsEcsData={ecsData}
refetchFlyoutData={refetchFlyoutData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
isReadOnly={isReadOnly}
loadingEventDetails={loading}
onAddIsolationStatusClick={showHostIsolationPanel}
scopeId={scopeId}
/>
</FlyoutFooterContainerPanel>
</>
);
};
export const EventDetailsPanel = React.memo(
EventDetailsPanelComponent,
(prevProps, nextProps) =>
deepEqual(prevProps.browserFields, nextProps.browserFields) &&
deepEqual(prevProps.expandedEvent, nextProps.expandedEvent) &&
prevProps.scopeId === nextProps.scopeId &&
prevProps.isDraggable === nextProps.isDraggable
);

View file

@ -1,58 +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 { i18n } from '@kbn/i18n';
export const CLOSE = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel',
{
defaultMessage: 'close',
}
);
export const EVENT_DETAILS_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.placeholder',
{
defaultMessage: 'Select an event to show event details',
}
);
export const EVENT_DETAILS = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.eventTitleLabel',
{
defaultMessage: 'Event details',
}
);
export const ALERT_DETAILS = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.alertTitleLabel',
{
defaultMessage: 'Alert details',
}
);
export const TECHNICAL_PREVIEW = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.technicalPreviewLabel',
{
defaultMessage: 'Technical Preview',
}
);
export const TECHNICAL_PREVIEW_DESCRIPTION = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.technicalPreviewDescription',
{
defaultMessage:
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
}
);
export const SHARE_ALERT = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.shareAlert',
{
defaultMessage: 'Share alert',
}
);

View file

@ -11,6 +11,7 @@ import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants';
import { buildAlertDetailPath } from '../../../../../common/utils/alert_detail_path';
import { useAppUrl } from '../../../../common/lib/kibana/hooks';
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
export const useGetAlertDetailsFlyoutLink = ({
_id,
_index,

View file

@ -51,6 +51,7 @@ function hostIsolationReducer(state: HostIsolationStateReducer, action: HostIsol
}
}
// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462
const useHostIsolationTools = () => {
const [
{ isolateAction, isHostIsolationPanelOpen, isIsolateActionSuccessBannerVisible },

View file

@ -10,7 +10,7 @@ import type { UseDetailPanelConfig } from './use_detail_panel';
import { useDetailPanel } from './use_detail_panel';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { SourcererScopeName } from '../../../../sourcerer/store/model';
import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import { TimelineId } from '../../../../../common/types/timeline';
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
import { TestProviders } from '../../../../common/mock';
import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock';
@ -74,34 +74,6 @@ describe('useDetailPanel', () => {
await waitForNextUpdate();
expect(result.current.openEventDetailsPanel).toBeDefined();
expect(result.current.shouldShowDetailsPanel).toBe(false);
expect(result.current.DetailsPanel).toBeNull();
});
});
test('should show the details panel', async () => {
mockGetExpandedDetail.mockImplementation(() => ({
[TimelineTabs.session]: {
panelView: 'somePanel',
},
}));
const updatedProps = {
...defaultProps,
tabType: TimelineTabs.session,
};
await act(async () => {
const { result, waitForNextUpdate } = renderUseDetailPanel(updatedProps);
await waitForNextUpdate();
expect(result.current.DetailsPanel).toMatchInlineSnapshot(`
<Memo(DetailsPanel)
browserFields={Object {}}
handleOnPanelClosed={[Function]}
scopeId="timeline-test"
tabType="session"
/>
`);
});
});
});

View file

@ -5,71 +5,32 @@
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import type { EntityType } from '@kbn/timelines-plugin/common';
import { dataTableSelectors } from '@kbn/securitysolution-data-table';
import { useMemo, useCallback } from 'react';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useKibana } from '../../../../common/lib/kibana';
import { isInTableScope, isTimelineScope } from '../../../../helpers';
import { timelineSelectors } from '../../../store';
import { useSourcererDataView } from '../../../../sourcerer/containers';
import type { SourcererScopeName } from '../../../../sourcerer/store/model';
import { TimelineTabs } from '../../../../../common/types/timeline';
import { timelineDefaults } from '../../../store/defaults';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { DetailsPanel as DetailsPanelComponent } from '..';
import { DocumentDetailsRightPanelKey } from '../../../../flyout/document_details/shared/constants/panel_keys';
export interface UseDetailPanelConfig {
entityType?: EntityType;
isFlyoutView?: boolean;
sourcererScope: SourcererScopeName;
scopeId: string;
tabType?: TimelineTabs;
}
export interface UseDetailPanelReturn {
openEventDetailsPanel: (eventId?: string, onClose?: () => void) => void;
DetailsPanel: JSX.Element | null;
shouldShowDetailsPanel: boolean;
}
export const useDetailPanel = ({
entityType,
isFlyoutView,
sourcererScope,
scopeId,
tabType = TimelineTabs.query,
}: UseDetailPanelConfig): UseDetailPanelReturn => {
const { telemetry } = useKibana().services;
const { browserFields, selectedPatterns, runtimeMappings } = useSourcererDataView(sourcererScope);
const { selectedPatterns } = useSourcererDataView(sourcererScope);
const { openFlyout } = useExpandableFlyoutApi();
const getScope = useMemo(() => {
if (isTimelineScope(scopeId)) {
return timelineSelectors.getTimelineByIdSelector();
} else if (isInTableScope(scopeId)) {
return dataTableSelectors.getTableByIdSelector();
}
}, [scopeId]);
const eventDetailsIndex = useMemo(() => selectedPatterns.join(','), [selectedPatterns]);
const expandedDetail = useDeepEqualSelector(
(state) => ((getScope && getScope(state, scopeId)) ?? timelineDefaults)?.expandedDetail
);
const shouldShowDetailsPanel = useMemo(() => {
if (
tabType &&
expandedDetail &&
expandedDetail[tabType] &&
!!expandedDetail[tabType]?.panelView
) {
return true;
}
return false;
}, [expandedDetail, tabType]);
const openEventDetailsPanel = useCallback(
(eventId?: string, onClose?: () => void) => {
openFlyout({
@ -90,33 +51,7 @@ export const useDetailPanel = ({
[openFlyout, eventDetailsIndex, scopeId, telemetry]
);
const DetailsPanel = useMemo(
() =>
shouldShowDetailsPanel ? (
<DetailsPanelComponent
browserFields={browserFields}
entityType={entityType}
handleOnPanelClosed={() => {}}
isFlyoutView={isFlyoutView}
runtimeMappings={runtimeMappings}
tabType={tabType}
scopeId={scopeId}
/>
) : null,
[
browserFields,
entityType,
isFlyoutView,
runtimeMappings,
shouldShowDetailsPanel,
tabType,
scopeId,
]
);
return {
openEventDetailsPanel,
shouldShowDetailsPanel,
DetailsPanel,
};
};

View file

@ -1,271 +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 { mount } from 'enzyme';
import React from 'react';
import { mockGlobalState, TestProviders, createMockStore } from '../../../common/mock';
import type { State } from '../../../common/store';
import { DetailsPanel } from '.';
import { TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import { EventDetailsPanel } from './event_details';
import { useSearchStrategy } from '../../../common/containers/use_search_strategy';
import type { ExpandedDetailTimeline } from '../../../../common/types';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
jest.mock('../../../common/containers/use_search_strategy', () => ({
useSearchStrategy: jest.fn(),
}));
jest.mock('../../../common/components/user_profiles/use_bulk_get_user_profiles', () => {
return {
useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../common/components/user_profiles/use_suggest_users', () => {
return {
useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../assistant/use_assistant_availability');
const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' });
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useLocation: () => mockUseLocation(),
};
});
describe('Details Panel Component', () => {
const state: State = {
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
...mockGlobalState.timeline.timelineById,
[TimelineId.test]: mockGlobalState.timeline.timelineById[TimelineId.test],
},
},
};
let store = createMockStore(state);
const dataLessExpandedDetail = {
[TimelineTabs.query]: {
panelView: 'hostDetail',
params: {},
},
};
const eventExpandedDetail: ExpandedDetailTimeline = {
[TimelineTabs.query]: {
panelView: 'eventDetail',
params: {
eventId: 'my-id',
indexName: 'my-index',
},
},
};
const eventPinnedExpandedDetail: ExpandedDetailTimeline = {
[TimelineTabs.pinned]: {
panelView: 'eventDetail',
params: {
eventId: 'my-id',
indexName: 'my-index',
},
},
};
const mockProps = {
browserFields: {},
handleOnPanelClosed: jest.fn(),
isFlyoutView: false,
runtimeMappings: {},
tabType: TimelineTabs.query,
scopeId: TimelineId.test,
};
const mockUseSearchStrategy = useSearchStrategy as jest.Mock;
describe('DetailsPanel: rendering', () => {
beforeEach(() => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasAssistantPrivilege: false,
isAssistantEnabled: true,
});
store = createMockStore(state);
});
test('it should not render the DetailsPanel if no expanded detail has been set in the reducer', () => {
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...mockProps} />
</TestProviders>
);
expect(wrapper.find('DetailsPanel')).toMatchSnapshot();
});
test('it should not render the DetailsPanel if an expanded detail with a panelView, but not params have been set', () => {
state.timeline.timelineById[TimelineId.test].expandedDetail =
dataLessExpandedDetail as ExpandedDetailTimeline; // Casting as the dataless doesn't meet the actual type requirements
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...mockProps} />
</TestProviders>
);
expect(wrapper.find('DetailsPanel')).toMatchSnapshot(`
<DetailsPanel
browserFields={Object {}}
handleOnPanelClosed={[MockFunction]}
isFlyoutView={false}
runtimeMappings={Object {}}
tabType="query"
scopeId="timeline-test"
/>
`);
});
});
describe('DetailsPanel:EventDetails: rendering', () => {
beforeEach(() => {
const mockState = {
...state,
timeline: {
...state.timeline,
timelineById: {
[TimelineId.test]: state.timeline.timelineById[TimelineId.test],
[TimelineId.active]: state.timeline.timelineById[TimelineId.test],
},
},
};
mockState.timeline.timelineById[TimelineId.active].expandedDetail = eventExpandedDetail;
mockState.timeline.timelineById[TimelineId.test].expandedDetail = eventExpandedDetail;
store = createMockStore(mockState);
mockUseSearchStrategy.mockReturnValue({
loading: true,
result: {
data: undefined,
totalCount: 0,
},
error: undefined,
search: jest.fn(),
refetch: jest.fn(),
inspect: {},
});
});
test('it should render the Event Details Panel when the panelView is set and the associated params are set', () => {
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...mockProps} />
</TestProviders>
);
expect(wrapper.find('EventDetailsPanelComponent').render()).toMatchSnapshot();
});
test('it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set', () => {
const currentProps = { ...mockProps, isFlyoutView: true };
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...currentProps} />
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="timeline:details-panel:flyout"]').first().render()
).toMatchSnapshot();
});
test('it should have the attributes isDraggable to be false when timelineId !== "active" and activeTab === "query"', () => {
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...mockProps} />
</TestProviders>
);
expect(wrapper.find(EventDetailsPanel).props().isDraggable).toBeFalsy();
});
test('it should have the attributes isDraggable to be true when timelineId === "active" and activeTab === "query"', () => {
const currentProps = {
...mockProps,
scopeId: TimelineId.active,
tabType: TimelineTabs.query,
};
const newState = {
...state,
timeline: {
...state.timeline,
timelineById: {
...state.timeline.timelineById,
[TimelineId.active]: state.timeline.timelineById[TimelineId.test],
},
},
};
newState.timeline.timelineById[TimelineId.active].activeTab = TimelineTabs.query;
newState.timeline.timelineById[TimelineId.active].expandedDetail = eventExpandedDetail;
store = createMockStore(newState);
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...currentProps} />
</TestProviders>
);
expect(wrapper.find(EventDetailsPanel).props().isDraggable).toBeTruthy();
});
});
describe('DetailsPanel:EventDetails: rendering in pinned tab', () => {
beforeEach(() => {
const mockState = {
...state,
timeline: {
...state.timeline,
timelineById: {
[TimelineId.test]: state.timeline.timelineById[TimelineId.test],
[TimelineId.active]: state.timeline.timelineById[TimelineId.test],
},
},
};
mockState.timeline.timelineById[TimelineId.active].activeTab = TimelineTabs.pinned;
mockState.timeline.timelineById[TimelineId.active].expandedDetail = eventPinnedExpandedDetail;
mockState.timeline.timelineById[TimelineId.test].expandedDetail = eventPinnedExpandedDetail;
mockState.timeline.timelineById[TimelineId.test].activeTab = TimelineTabs.pinned;
store = createMockStore(mockState);
});
test('it should have the attributes isDraggable to be false when timelineId !== "active" and activeTab === "pinned"', () => {
const currentProps = { ...mockProps, tabType: TimelineTabs.pinned };
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...currentProps} />
</TestProviders>
);
expect(wrapper.find(EventDetailsPanel).props().isDraggable).toBeFalsy();
});
test('it should have the attributes isDraggable to be false when timelineId === "active" and activeTab === "pinned"', () => {
const currentProps = {
...mockProps,
tabType: TimelineTabs.pinned,
scopeId: TimelineId.active,
};
const wrapper = mount(
<TestProviders store={store}>
<DetailsPanel {...currentProps} />
</TestProviders>
);
expect(wrapper.find(EventDetailsPanel).props().isDraggable).toBeFalsy();
});
});
});

View file

@ -1,142 +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 React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import type { EuiFlyoutProps } from '@elastic/eui';
import { EuiFlyout } from '@elastic/eui';
import type { EntityType } from '@kbn/timelines-plugin/common';
import { dataTableActions, dataTableSelectors } from '@kbn/securitysolution-data-table';
import { getScopedActions, isInTableScope, isTimelineScope } from '../../../helpers';
import { timelineSelectors } from '../../store';
import { timelineDefaults } from '../../store/defaults';
import type { BrowserFields } from '../../../common/containers/source';
import type { RunTimeMappings } from '../../../sourcerer/store/model';
import { TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { EventDetailsPanel } from './event_details';
interface DetailsPanelProps {
browserFields: BrowserFields;
entityType?: EntityType;
handleOnPanelClosed?: () => void;
isFlyoutView?: boolean;
runtimeMappings: RunTimeMappings;
tabType?: TimelineTabs;
scopeId: string;
isReadOnly?: boolean;
}
const detailsPanelStyleProp = { zIndex: 1001 };
/**
* This panel is used in both the main timeline as well as the flyouts on the host, detection, cases, and network pages.
* To prevent duplication the `isFlyoutView` prop is passed to determine the layout that should be used
* `tabType` defaults to query and `handleOnPanelClosed` defaults to unsetting the default query tab which is used for the flyout panel
*/
export const DetailsPanel = React.memo(
({
browserFields,
entityType,
handleOnPanelClosed,
isFlyoutView,
runtimeMappings,
tabType,
scopeId,
isReadOnly,
}: DetailsPanelProps) => {
const dispatch = useDispatch();
const getScope = useMemo(() => {
if (isTimelineScope(scopeId)) {
return timelineSelectors.getTimelineByIdSelector();
} else if (isInTableScope(scopeId)) {
return dataTableSelectors.getTableByIdSelector();
}
}, [scopeId]);
const expandedDetail = useDeepEqualSelector(
(state) => ((getScope && getScope(state, scopeId)) ?? timelineDefaults)?.expandedDetail
);
useEffect(() => {
/**
* Removes the flyout from redux when it is unmounted as it's also stored in localStorage
* This only works when navigating within the app, if navigating via the url bar,
* the localStorage state will be maintained
* */
return () => {
dispatch(
dataTableActions.toggleDetailPanel({
id: scopeId,
})
);
};
}, [dispatch, scopeId]);
// To be used primarily in the flyout scenario where we don't want to maintain the tabType
const defaultOnPanelClose = useCallback(() => {
const scopedActions = getScopedActions(scopeId);
if (scopedActions) {
dispatch(scopedActions.toggleDetailPanel({ id: scopeId }));
}
}, [dispatch, scopeId]);
const activeTab: TimelineTabs = tabType ?? TimelineTabs.query;
const closePanel = useCallback(() => {
if (handleOnPanelClosed) handleOnPanelClosed();
else defaultOnPanelClose();
}, [defaultOnPanelClose, handleOnPanelClosed]);
if (!expandedDetail) return null;
const currentTabDetail = expandedDetail[activeTab];
if (!currentTabDetail?.panelView) return null;
let visiblePanel = null; // store in variable to make return statement more readable
let panelSize: EuiFlyoutProps['size'] = 's';
let flyoutUniqueKey = scopeId;
const isDraggable = scopeId === TimelineId.active && activeTab === TimelineTabs.query;
if (currentTabDetail?.panelView === 'eventDetail' && currentTabDetail?.params?.eventId) {
panelSize = 'm';
flyoutUniqueKey = currentTabDetail.params.eventId;
visiblePanel = (
<EventDetailsPanel
browserFields={browserFields}
entityType={entityType}
expandedEvent={currentTabDetail?.params}
handleOnEventClosed={closePanel}
isDraggable={isDraggable}
isFlyoutView={isFlyoutView}
runtimeMappings={runtimeMappings}
tabType={activeTab}
scopeId={scopeId}
isReadOnly={isReadOnly}
/>
);
}
return isFlyoutView ? (
<EuiFlyout
data-test-subj="timeline:details-panel:flyout"
size={panelSize}
style={detailsPanelStyleProp}
onClose={closePanel}
ownFocus={false}
key={flyoutUniqueKey}
>
{visiblePanel}
</EuiFlyout>
) : (
visiblePanel
);
}
);
DetailsPanel.displayName = 'DetailsPanel';

View file

@ -18,8 +18,8 @@ import type {
ColumnHeaderOptions,
CellValueElementProps,
RowRenderer,
TimelineTabs,
} from '../../../../../../common/types/timeline';
import { TimelineTabs } from '../../../../../../common/types/timeline';
import type {
TimelineItem,
TimelineNonEcsData,
@ -32,12 +32,11 @@ import { useEventDetailsWidthContext } from '../../../../../common/components/ev
import { EventColumnView } from './event_column_view';
import type { inputsModel } from '../../../../../common/store';
import { appSelectors } from '../../../../../common/store';
import { timelineActions, timelineSelectors } from '../../../../store';
import { timelineActions } from '../../../../store';
import type { TimelineResultNote } from '../../../open_timeline/types';
import { getRowRenderer } from '../renderers/get_row_renderer';
import { StatefulRowRenderer } from './stateful_row_renderer';
import { NOTES_BUTTON_CLASS_NAME } from '../../properties/helpers';
import { timelineDefaults } from '../../../../store/defaults';
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
import type {
ControlColumnProps,
@ -121,19 +120,9 @@ const StatefulEventComponent: React.FC<Props> = ({
const [, setFocusedNotes] = useState<{ [eventId: string]: boolean }>({});
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const expandedDetail = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail ?? {}
);
const activeTab = tabType ?? TimelineTabs.query;
const activeExpandedDetail = expandedDetail[activeTab];
const eventId = event._id;
const isDetailPanelExpanded: boolean =
(activeExpandedDetail?.panelView === 'eventDetail' &&
activeExpandedDetail?.params?.eventId === eventId) ||
false;
const isDetailPanelExpanded: boolean = false;
const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []);
const notesById = useDeepEqualSelector(getNotesByIds);

View file

@ -5,184 +5,10 @@
* 2.0.
*/
import {
eventHasNotes,
eventIsPinned,
getPinOnClick,
getPinTooltip,
stringifyEvent,
} from './helpers';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { eventHasNotes, eventIsPinned, getPinOnClick, getPinTooltip } from './helpers';
import { TimelineType } from '../../../../../common/api/timeline';
describe('helpers', () => {
describe('stringifyEvent', () => {
test('it omits __typename when it appears at arbitrary levels', () => {
const toStringify: Ecs = {
__typename: 'level 0',
_id: '4',
timestamp: '2018-11-08T19:03:25.937Z',
host: {
__typename: 'level 1',
name: ['suricata'],
ip: ['192.168.0.1'],
},
event: {
id: ['4'],
category: ['Attempted Administrator Privilege Gain'],
type: ['Alert'],
module: ['suricata'],
severity: [1],
},
source: {
ip: ['192.168.0.3'],
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'],
signature_id: [4],
__typename: 'level 2',
},
},
},
user: {
id: ['4'],
name: ['jack.black'],
},
geo: {
region_name: ['neither'],
country_iso_code: ['sasquatch'],
},
} as Ecs; // as cast so that `__typename` can be added for the tests even though it is not part of ECS
const expected: Ecs = {
_id: '4',
timestamp: '2018-11-08T19:03:25.937Z',
host: {
name: ['suricata'],
ip: ['192.168.0.1'],
},
event: {
id: ['4'],
category: ['Attempted Administrator Privilege Gain'],
type: ['Alert'],
module: ['suricata'],
severity: [1],
},
source: {
ip: ['192.168.0.3'],
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'],
signature_id: [4],
},
},
},
user: {
id: ['4'],
name: ['jack.black'],
},
geo: {
region_name: ['neither'],
country_iso_code: ['sasquatch'],
},
};
expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected);
});
test('it omits null and undefined values at arbitrary levels, for arbitrary data types', () => {
const expected: Ecs = {
_id: '4',
host: {},
event: {
id: ['4'],
category: ['theory'],
type: ['Alert'],
module: ['me'],
severity: [1],
},
source: {
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['dance moves'],
},
},
},
user: {
id: ['4'],
name: ['no use for a'],
},
geo: {
region_name: ['bizzaro'],
country_iso_code: ['world'],
},
};
const toStringify: Ecs = {
_id: '4',
host: {},
event: {
id: ['4'],
category: ['theory'],
type: ['Alert'],
module: ['me'],
severity: [1],
},
source: {
ip: undefined,
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['dance moves'],
signature_id: undefined,
},
},
},
user: {
id: ['4'],
name: ['no use for a'],
},
geo: {
region_name: ['bizzaro'],
country_iso_code: ['world'],
},
};
expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected);
});
});
describe('eventHasNotes', () => {
test('it returns false for when notes is empty', () => {
expect(eventHasNotes([])).toEqual(false);

Some files were not shown because too many files have changed in this diff Show more