mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution] - remove old event details flyout and panel (#187846)
This commit is contained in:
parent
e1277b829f
commit
d0b156d15a
135 changed files with 51 additions and 9175 deletions
|
@ -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>>;
|
||||
|
|
|
@ -24,7 +24,6 @@ export const mockGlobalState = {
|
|||
defaultColumns: defaultHeaders,
|
||||
dataViewId: 'security-solution-default',
|
||||
deletedEventIds: [],
|
||||
expandedDetail: {},
|
||||
filters: [],
|
||||
indexNames: ['.alerts-security.alerts-default'],
|
||||
isSelectAllChecked: false,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -64,7 +64,6 @@ export const tableDefaults: SubsetDataTableModel = {
|
|||
defaultColumns: defaultHeaders,
|
||||
dataViewId: null,
|
||||
deletedEventIds: [],
|
||||
expandedDetail: {},
|
||||
filters: [],
|
||||
indexNames: [],
|
||||
isSelectAllChecked: false,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -23,9 +23,6 @@ export const timelineIntegrationMock = {
|
|||
hooks: {
|
||||
useInsertTimeline: jest.fn(),
|
||||
},
|
||||
ui: {
|
||||
renderTimelineDetailsPanel: () => mockTimelineComponent('timeline-details-panel'),
|
||||
},
|
||||
};
|
||||
|
||||
export const useTimelineContextMock = useTimelineContext as jest.Mock;
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>>;
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 '';
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 '';
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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,
|
||||
};
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -188,7 +188,6 @@ const timeline = {
|
|||
'threat_match',
|
||||
'zeek',
|
||||
],
|
||||
expandedDetail: {},
|
||||
filters: [],
|
||||
kqlQuery: { filterQuery: null },
|
||||
indexNames: ['.alerts-security.alerts-default'],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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> =
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -381,7 +381,6 @@ describe('alert actions', () => {
|
|||
eventIdToNoteIds: {},
|
||||
eventType: 'all',
|
||||
excludedRowRendererIds: [],
|
||||
expandedDetail: {},
|
||||
filters: [
|
||||
{
|
||||
$state: {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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);
|
|
@ -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();
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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';
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
|
@ -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';
|
||||
|
|
|
@ -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(), []);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue