[CTI] Adds Threat Intel Tab to Alert Summary Flyout (#97185)
This commit is contained in:
parent
b5effc20a4
commit
a254f0f810
|
@ -19,10 +19,14 @@ export const INDICATOR_MATCHED_TYPE = `${INDICATOR_DESTINATION_PATH}.${MATCHED_T
|
|||
export const EVENT_DATASET = 'event.dataset';
|
||||
export const EVENT_REFERENCE = 'event.reference';
|
||||
export const PROVIDER = 'provider';
|
||||
export const FIRSTSEEN = 'first_seen';
|
||||
|
||||
export const INDICATOR_DATASET = `${INDICATOR_DESTINATION_PATH}.${EVENT_DATASET}`;
|
||||
export const INDICATOR_REFERENCE = `${INDICATOR_DESTINATION_PATH}.${EVENT_REFERENCE}`;
|
||||
export const INDICATOR_EVENT_URL = `${INDICATOR_DESTINATION_PATH}.event.url`;
|
||||
export const INDICATOR_FIRSTSEEN = `${INDICATOR_DESTINATION_PATH}.${FIRSTSEEN}`;
|
||||
export const INDICATOR_LASTSEEN = `${INDICATOR_DESTINATION_PATH}.last_seen`;
|
||||
export const INDICATOR_PROVIDER = `${INDICATOR_DESTINATION_PATH}.${PROVIDER}`;
|
||||
export const INDICATOR_REFERENCE = `${INDICATOR_DESTINATION_PATH}.${EVENT_REFERENCE}`;
|
||||
|
||||
export const CTI_ROW_RENDERER_FIELDS = [
|
||||
INDICATOR_MATCHED_ATOMIC,
|
||||
|
@ -32,3 +36,11 @@ export const CTI_ROW_RENDERER_FIELDS = [
|
|||
INDICATOR_REFERENCE,
|
||||
INDICATOR_PROVIDER,
|
||||
];
|
||||
|
||||
export const SORTED_THREAT_SUMMARY_FIELDS = [
|
||||
INDICATOR_MATCHED_FIELD,
|
||||
INDICATOR_MATCHED_TYPE,
|
||||
INDICATOR_PROVIDER,
|
||||
INDICATOR_FIRSTSEEN,
|
||||
INDICATOR_LASTSEEN,
|
||||
];
|
||||
|
|
|
@ -10,10 +10,13 @@ import {
|
|||
EuiDescriptionList,
|
||||
EuiDescriptionListDescription,
|
||||
EuiDescriptionListTitle,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { get, getOr } from 'lodash/fp';
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
import { BrowserFields } from '../../../../common/search_strategy/index_fields';
|
||||
|
@ -33,7 +36,6 @@ import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../networ
|
|||
import { SummaryView } from './summary_view';
|
||||
import { AlertSummaryRow, getSummaryColumns, SummaryRow } from './helpers';
|
||||
import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async';
|
||||
import * as i18n from './translations';
|
||||
import { LineClamp } from '../line_clamp';
|
||||
|
||||
const StyledEuiDescriptionList = styled(EuiDescriptionList)`
|
||||
|
@ -166,7 +168,8 @@ const AlertSummaryViewComponent: React.FC<{
|
|||
data: TimelineEventsDetailsItem[];
|
||||
eventId: string;
|
||||
timelineId: string;
|
||||
}> = ({ browserFields, data, eventId, timelineId }) => {
|
||||
title?: string;
|
||||
}> = ({ browserFields, data, eventId, timelineId, title }) => {
|
||||
const summaryRows = useMemo(() => getSummaryRows({ browserFields, data, eventId, timelineId }), [
|
||||
browserFields,
|
||||
data,
|
||||
|
@ -184,7 +187,8 @@ const AlertSummaryViewComponent: React.FC<{
|
|||
|
||||
return (
|
||||
<>
|
||||
<SummaryView summaryColumns={summaryColumns} summaryRows={summaryRows} />
|
||||
<EuiSpacer size="l" />
|
||||
<SummaryView summaryColumns={summaryColumns} summaryRows={summaryRows} title={title} />
|
||||
{maybeRule?.note && (
|
||||
<StyledEuiDescriptionList data-test-subj={`summary-view-guide`} compressed>
|
||||
<EuiDescriptionListTitle>{i18n.INVESTIGATION_GUIDE}</EuiDescriptionListTitle>
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { useMountAppended } from '../../utils/use_mount_appended';
|
||||
import { getMockTheme } from '../../lib/kibana/kibana_react.mock';
|
||||
import { EmptyThreatDetailsView } from './empty_threat_details_view';
|
||||
|
||||
jest.mock('../../lib/kibana');
|
||||
|
||||
describe('EmptyThreatDetailsView', () => {
|
||||
const mount = useMountAppended();
|
||||
const mockTheme = getMockTheme({
|
||||
eui: {
|
||||
euiBreakpoints: {
|
||||
l: '1200px',
|
||||
},
|
||||
paddingSizes: {
|
||||
m: '8px',
|
||||
xl: '32px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders correct items', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={mockTheme}>
|
||||
<EmptyThreatDetailsView />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="empty-threat-details-view"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('renders link to docs', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={mockTheme}>
|
||||
<EmptyThreatDetailsView />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.find('a').exists()).toEqual(true);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { EuiLink, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import * as i18n from './translations';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
|
||||
const EmptyThreatDetailsViewContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Span = styled.span`
|
||||
color: ${({ theme }) => theme.eui.euiColorDarkShade};
|
||||
line-height: 1.8em;
|
||||
text-align: center;
|
||||
padding: ${({ theme }) => `${theme.eui.paddingSizes.m} ${theme.eui.paddingSizes.xl}`};
|
||||
`;
|
||||
|
||||
const EmptyThreatDetailsViewComponent: React.FC<{}> = () => {
|
||||
const threatIntelDocsUrl = `${
|
||||
useKibana().services.docLinks.links.filebeat.base
|
||||
}/filebeat-module-threatintel.html`;
|
||||
|
||||
return (
|
||||
<EmptyThreatDetailsViewContainer data-test-subj="empty-threat-details-view">
|
||||
<EuiSpacer size="xxl" />
|
||||
<EuiTitle size="m">
|
||||
<h2>{i18n.NO_ENRICHMENT_FOUND}</h2>
|
||||
</EuiTitle>
|
||||
<Span>
|
||||
{i18n.IF_CTI_NOT_ENABLED}
|
||||
<EuiLink href={threatIntelDocsUrl} target="_blank">
|
||||
{i18n.CHECK_DOCS}
|
||||
</EuiLink>
|
||||
</Span>
|
||||
</EmptyThreatDetailsViewContainer>
|
||||
);
|
||||
};
|
||||
|
||||
EmptyThreatDetailsViewComponent.displayName = 'EmptyThreatDetailsView';
|
||||
|
||||
export const EmptyThreatDetailsView = React.memo(EmptyThreatDetailsViewComponent);
|
|
@ -13,7 +13,7 @@ import '../../mock/match_media';
|
|||
import '../../mock/react_beautiful_dnd';
|
||||
import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock';
|
||||
|
||||
import { EventDetails, EventsViewType, EventView, ThreatView } from './event_details';
|
||||
import { EventDetails, EventsViewType } from './event_details';
|
||||
import { mockBrowserFields } from '../../containers/source/mock';
|
||||
import { useMountAppended } from '../../utils/use_mount_appended';
|
||||
import { mockAlertDetailsData } from './__mocks__';
|
||||
|
@ -32,8 +32,7 @@ describe('EventDetails', () => {
|
|||
onThreatViewSelected: jest.fn(),
|
||||
timelineTabType: TimelineTabs.query,
|
||||
timelineId: 'test',
|
||||
eventView: EventsViewType.summaryView as EventView,
|
||||
threatView: EventsViewType.threatSummaryView as ThreatView,
|
||||
eventView: EventsViewType.summaryView,
|
||||
};
|
||||
|
||||
const alertsProps = {
|
||||
|
@ -78,13 +77,14 @@ describe('EventDetails', () => {
|
|||
});
|
||||
|
||||
describe('alerts tabs', () => {
|
||||
['Summary', 'Table', 'JSON View'].forEach((tab) => {
|
||||
['Summary', 'Threat Intel', 'Table', 'JSON View'].forEach((tab) => {
|
||||
test(`it renders the ${tab} tab`, () => {
|
||||
const expectedCopy = tab === 'Threat Intel' ? `${tab} (1)` : tab;
|
||||
expect(
|
||||
alertsWrapper
|
||||
.find('[data-test-subj="eventDetails"]')
|
||||
.find('[role="tablist"]')
|
||||
.containsMatchingElement(<span>{tab}</span>)
|
||||
.containsMatchingElement(<span>{expectedCopy}</span>)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
@ -99,27 +99,4 @@ describe('EventDetails', () => {
|
|||
).toEqual('Summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('threat tabs', () => {
|
||||
['Threat Summary', 'Threat Details'].forEach((tab) => {
|
||||
test(`it renders the ${tab} tab`, () => {
|
||||
expect(
|
||||
alertsWrapper
|
||||
.find('[data-test-subj="threatDetails"]')
|
||||
.find('[role="tablist"]')
|
||||
.containsMatchingElement(<span>{tab}</span>)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('the Summary tab is selected by default', () => {
|
||||
expect(
|
||||
alertsWrapper
|
||||
.find('[data-test-subj="threatDetails"]')
|
||||
.find('.euiTab-isSelected')
|
||||
.first()
|
||||
.text()
|
||||
).toEqual('Threat Summary');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,31 +6,37 @@
|
|||
*/
|
||||
|
||||
import { EuiTabbedContent, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { BrowserFields } from '../../containers/source';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
|
||||
import { EventFieldsBrowser } from './event_fields_browser';
|
||||
import { JsonView } from './json_view';
|
||||
import * as i18n from './translations';
|
||||
import { AlertSummaryView } from './alert_summary_view';
|
||||
import { ThreatSummaryView } from './threat_summary_view';
|
||||
import { ThreatDetailsView } from './threat_details_view';
|
||||
import * as i18n from './translations';
|
||||
import { AlertSummaryView } from './alert_summary_view';
|
||||
import { BrowserFields } from '../../containers/source';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
|
||||
import { TimelineTabs } from '../../../../common/types/timeline';
|
||||
import { INDICATOR_DESTINATION_PATH } from '../../../../common/constants';
|
||||
import { getDataFromSourceHits } from '../../../../common/utils/field_formatters';
|
||||
|
||||
export type EventView =
|
||||
interface EventViewTab {
|
||||
id: EventViewId;
|
||||
name: string;
|
||||
content: JSX.Element;
|
||||
}
|
||||
|
||||
export type EventViewId =
|
||||
| EventsViewType.tableView
|
||||
| EventsViewType.jsonView
|
||||
| EventsViewType.summaryView;
|
||||
export type ThreatView = EventsViewType.threatSummaryView | EventsViewType.threatDetailsView;
|
||||
| EventsViewType.summaryView
|
||||
| EventsViewType.threatIntelView;
|
||||
export enum EventsViewType {
|
||||
tableView = 'table-view',
|
||||
jsonView = 'json-view',
|
||||
summaryView = 'summary-view',
|
||||
threatSummaryView = 'threat-summary-view',
|
||||
threatDetailsView = 'threat-details-view',
|
||||
threatIntelView = 'threat-intel-view',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
@ -38,10 +44,6 @@ interface Props {
|
|||
data: TimelineEventsDetailsItem[];
|
||||
id: string;
|
||||
isAlert: boolean;
|
||||
eventView: EventView;
|
||||
threatView: ThreatView;
|
||||
onEventViewSelected: (selected: EventView) => void;
|
||||
onThreatViewSelected: (selected: ThreatView) => void;
|
||||
timelineTabType: TimelineTabs | 'flyout';
|
||||
timelineId: string;
|
||||
}
|
||||
|
@ -56,7 +58,8 @@ const StyledEuiTabbedContent = styled(EuiTabbedContent)`
|
|||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: scroll;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 7px;
|
||||
|
@ -77,132 +80,125 @@ const TabContentWrapper = styled.div`
|
|||
const EventDetailsComponent: React.FC<Props> = ({
|
||||
browserFields,
|
||||
data,
|
||||
eventView,
|
||||
id,
|
||||
isAlert,
|
||||
onEventViewSelected,
|
||||
onThreatViewSelected,
|
||||
threatView,
|
||||
timelineId,
|
||||
timelineTabType,
|
||||
}) => {
|
||||
const handleEventTabClick = useCallback((e) => onEventViewSelected(e.id), [onEventViewSelected]);
|
||||
const handleThreatTabClick = useCallback((e) => onThreatViewSelected(e.id), [
|
||||
onThreatViewSelected,
|
||||
]);
|
||||
|
||||
const alerts = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: EventsViewType.summaryView,
|
||||
name: i18n.SUMMARY,
|
||||
content: (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<AlertSummaryView
|
||||
{...{
|
||||
data,
|
||||
eventId: id,
|
||||
browserFields,
|
||||
timelineId,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
[data, id, browserFields, timelineId]
|
||||
);
|
||||
const tabs: EuiTabbedContentTab[] = useMemo(
|
||||
() => [
|
||||
...(isAlert ? alerts : []),
|
||||
{
|
||||
id: EventsViewType.tableView,
|
||||
name: i18n.TABLE,
|
||||
content: (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<EventFieldsBrowser
|
||||
browserFields={browserFields}
|
||||
data={data}
|
||||
eventId={id}
|
||||
timelineId={timelineId}
|
||||
timelineTabType={timelineTabType}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: EventsViewType.jsonView,
|
||||
'data-test-subj': 'jsonViewTab',
|
||||
name: i18n.JSON_VIEW,
|
||||
content: (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<TabContentWrapper>
|
||||
<JsonView data={data} />
|
||||
</TabContentWrapper>
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
[alerts, browserFields, data, id, isAlert, timelineId, timelineTabType]
|
||||
const [selectedTabId, setSelectedTabId] = useState<EventViewId>(EventsViewType.summaryView);
|
||||
const handleTabClick = useCallback(
|
||||
(tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EventViewId),
|
||||
[setSelectedTabId]
|
||||
);
|
||||
|
||||
const selectedEventTab = useMemo(() => tabs.find((t) => t.id === eventView) ?? tabs[0], [
|
||||
tabs,
|
||||
eventView,
|
||||
]);
|
||||
const threatData = useMemo(() => {
|
||||
if (isAlert && data) {
|
||||
const threatIndicator = data.find(
|
||||
({ field, originalValue }) => field === INDICATOR_DESTINATION_PATH && originalValue
|
||||
);
|
||||
if (!threatIndicator) return [];
|
||||
const { originalValue } = threatIndicator;
|
||||
const values = Array.isArray(originalValue) ? originalValue : [originalValue];
|
||||
return values.map((value) => getDataFromSourceHits(JSON.parse(value)));
|
||||
}
|
||||
return [];
|
||||
}, [data, isAlert]);
|
||||
|
||||
const isThreatPresent: boolean = useMemo(
|
||||
const threatCount = useMemo(() => threatData.length, [threatData.length]);
|
||||
|
||||
const summaryTab = useMemo(
|
||||
() =>
|
||||
selectedEventTab.id === tabs[0].id &&
|
||||
isAlert &&
|
||||
data.some((item) => item.field === INDICATOR_DESTINATION_PATH),
|
||||
[tabs, selectedEventTab, isAlert, data]
|
||||
isAlert
|
||||
? {
|
||||
id: EventsViewType.summaryView,
|
||||
name: i18n.SUMMARY,
|
||||
content: (
|
||||
<>
|
||||
<AlertSummaryView
|
||||
{...{
|
||||
data,
|
||||
eventId: id,
|
||||
browserFields,
|
||||
timelineId,
|
||||
title: threatCount ? i18n.ALERT_SUMMARY : undefined,
|
||||
}}
|
||||
/>
|
||||
{threatCount > 0 && <ThreatSummaryView {...{ data, timelineId, eventId: id }} />}
|
||||
</>
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
[browserFields, data, id, isAlert, timelineId, threatCount]
|
||||
);
|
||||
|
||||
const threatTabs: EuiTabbedContentTab[] = useMemo(() => {
|
||||
return isAlert && isThreatPresent
|
||||
? [
|
||||
{
|
||||
id: EventsViewType.threatSummaryView,
|
||||
name: i18n.THREAT_SUMMARY,
|
||||
content: <ThreatSummaryView {...{ data, eventId: id, timelineId }} />,
|
||||
},
|
||||
{
|
||||
id: EventsViewType.threatDetailsView,
|
||||
name: i18n.THREAT_DETAILS,
|
||||
content: <ThreatDetailsView data={data} />,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [data, id, isAlert, timelineId, isThreatPresent]);
|
||||
|
||||
const selectedThreatTab = useMemo(
|
||||
() => threatTabs.find((t) => t.id === threatView) ?? threatTabs[0],
|
||||
[threatTabs, threatView]
|
||||
const threatIntelTab = useMemo(
|
||||
() =>
|
||||
isAlert
|
||||
? {
|
||||
id: EventsViewType.threatIntelView,
|
||||
name: `${i18n.THREAT_INTEL} (${threatCount})`,
|
||||
content: <ThreatDetailsView threatData={threatData} />,
|
||||
}
|
||||
: undefined,
|
||||
[isAlert, threatCount, threatData]
|
||||
);
|
||||
|
||||
const tableTab = useMemo(
|
||||
() => ({
|
||||
id: EventsViewType.tableView,
|
||||
name: i18n.TABLE,
|
||||
content: (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<EventFieldsBrowser
|
||||
browserFields={browserFields}
|
||||
data={data}
|
||||
eventId={id}
|
||||
timelineId={timelineId}
|
||||
timelineTabType={timelineTabType}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
[browserFields, data, id, timelineId, timelineTabType]
|
||||
);
|
||||
|
||||
const jsonTab = useMemo(
|
||||
() => ({
|
||||
id: EventsViewType.jsonView,
|
||||
'data-test-subj': 'jsonViewTab',
|
||||
name: i18n.JSON_VIEW,
|
||||
content: (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<TabContentWrapper>
|
||||
<JsonView data={data} />
|
||||
</TabContentWrapper>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
[data]
|
||||
);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
return [summaryTab, threatIntelTab, tableTab, jsonTab].filter(
|
||||
(tab: EventViewTab | undefined): tab is EventViewTab => !!tab
|
||||
);
|
||||
}, [summaryTab, threatIntelTab, tableTab, jsonTab]);
|
||||
|
||||
const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [
|
||||
tabs,
|
||||
selectedTabId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledEuiTabbedContent
|
||||
data-test-subj="eventDetails"
|
||||
tabs={tabs}
|
||||
selectedTab={selectedEventTab}
|
||||
onTabClick={handleEventTabClick}
|
||||
key="event-summary-tabs"
|
||||
/>
|
||||
{isThreatPresent && (
|
||||
<StyledEuiTabbedContent
|
||||
data-test-subj="threatDetails"
|
||||
tabs={threatTabs}
|
||||
selectedTab={selectedThreatTab}
|
||||
onTabClick={handleThreatTabClick}
|
||||
key="threat-summary-tabs"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<StyledEuiTabbedContent
|
||||
data-test-subj="eventDetails"
|
||||
tabs={tabs}
|
||||
selectedTab={selectedTab}
|
||||
onTabClick={handleTabClick}
|
||||
key="event-summary-tabs"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -225,7 +225,7 @@ export const getSummaryColumns = (
|
|||
field: 'title',
|
||||
truncateText: false,
|
||||
render: getTitle,
|
||||
width: '120px',
|
||||
width: '160px',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui';
|
||||
import { EuiInMemoryTable, EuiBasicTableColumn, EuiTitle, EuiHorizontalRule } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
@ -27,18 +27,47 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
|
|||
}
|
||||
`;
|
||||
|
||||
const StyledEuiTitle = styled(EuiTitle)`
|
||||
color: ${({ theme }) => theme.eui.euiColorDarkShade};
|
||||
text-transform: lowercase;
|
||||
padding-top: ${({ theme }) => theme.eui.paddingSizes.s};
|
||||
h2 {
|
||||
min-width: 120px;
|
||||
}
|
||||
hr {
|
||||
max-width: 75%;
|
||||
}
|
||||
`;
|
||||
|
||||
const FlexDiv = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
export const SummaryViewComponent: React.FC<{
|
||||
title?: string;
|
||||
summaryColumns: Array<EuiBasicTableColumn<SummaryRow>>;
|
||||
summaryRows: SummaryRow[];
|
||||
dataTestSubj?: string;
|
||||
}> = ({ summaryColumns, summaryRows, dataTestSubj = 'summary-view' }) => {
|
||||
}> = ({ summaryColumns, summaryRows, dataTestSubj = 'summary-view', title }) => {
|
||||
return (
|
||||
<StyledEuiInMemoryTable
|
||||
data-test-subj={dataTestSubj}
|
||||
items={summaryRows}
|
||||
columns={summaryColumns}
|
||||
compressed
|
||||
/>
|
||||
<>
|
||||
{title && (
|
||||
<StyledEuiTitle size="xxs">
|
||||
<FlexDiv>
|
||||
<h2>{title}</h2>
|
||||
<EuiHorizontalRule margin="none" />
|
||||
</FlexDiv>
|
||||
</StyledEuiTitle>
|
||||
)}
|
||||
<StyledEuiInMemoryTable
|
||||
data-test-subj={dataTestSubj}
|
||||
items={summaryRows}
|
||||
columns={summaryColumns}
|
||||
compressed
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -8,8 +8,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { ThreatDetailsView } from './threat_details_view';
|
||||
import { mockAlertDetailsData } from './__mocks__';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
|
||||
import { TestProviders } from '../../mock';
|
||||
import { useMountAppended } from '../../utils/use_mount_appended';
|
||||
|
@ -20,11 +18,56 @@ jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async'
|
|||
};
|
||||
});
|
||||
|
||||
const props = {
|
||||
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
|
||||
eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31',
|
||||
timelineId: 'detections-page',
|
||||
};
|
||||
const mostRecentDate = '2021-04-25T18:17:00.000Z';
|
||||
|
||||
const threatData = [
|
||||
[
|
||||
{
|
||||
category: 'matched',
|
||||
field: 'matched.field',
|
||||
isObjectArray: false,
|
||||
originalValue: ['test_field_2'],
|
||||
values: ['test_field_2'],
|
||||
},
|
||||
{
|
||||
category: 'first_seen',
|
||||
field: 'first_seen',
|
||||
isObjectArray: false,
|
||||
originalValue: ['2019-04-25T18:17:00.000Z'],
|
||||
values: ['2019-04-25T18:17:00.000Z'],
|
||||
},
|
||||
{
|
||||
category: 'event',
|
||||
field: 'event.reference',
|
||||
isObjectArray: false,
|
||||
originalValue: ['https://test.com/'],
|
||||
values: ['https://test.com/'],
|
||||
},
|
||||
{
|
||||
category: 'event',
|
||||
field: 'event.url',
|
||||
isObjectArray: false,
|
||||
originalValue: ['https://test2.com/'],
|
||||
values: ['https://test2.com/'],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
category: 'first_seen',
|
||||
field: 'first_seen',
|
||||
isObjectArray: false,
|
||||
originalValue: [mostRecentDate],
|
||||
values: [mostRecentDate],
|
||||
},
|
||||
{
|
||||
category: 'matched',
|
||||
field: 'matched.field',
|
||||
isObjectArray: false,
|
||||
originalValue: ['test_field'],
|
||||
values: ['test_field'],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe('ThreatDetailsView', () => {
|
||||
const mount = useMountAppended();
|
||||
|
@ -36,9 +79,36 @@ describe('ThreatDetailsView', () => {
|
|||
test('render correct items', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ThreatDetailsView {...props} />
|
||||
<ThreatDetailsView threatData={threatData} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="threat-details-view-0"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('renders empty view if there are no items', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ThreatDetailsView threatData={[[]]} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="empty-threat-details-view"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('renders link for event.url and event.reference', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ThreatDetailsView threatData={threatData} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.find('a').length).toEqual(2);
|
||||
});
|
||||
|
||||
test('orders items by first_seen', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ThreatDetailsView threatData={threatData} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.find('.euiToolTipAnchor span').at(0).text()).toEqual(mostRecentDate);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,51 +10,50 @@ import {
|
|||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiSpacer,
|
||||
EuiToolTip,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
import { isEmpty } from 'fp-ts/Array';
|
||||
import { SummaryView } from './summary_view';
|
||||
import { getSummaryColumns, SummaryRow, ThreatDetailsRow } from './helpers';
|
||||
import { getDataFromSourceHits } from '../../../../common/utils/field_formatters';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
import { INDICATOR_DESTINATION_PATH } from '../../../../common/constants';
|
||||
import {
|
||||
FIRSTSEEN,
|
||||
INDICATOR_EVENT_URL,
|
||||
INDICATOR_REFERENCE,
|
||||
} from '../../../../common/cti/constants';
|
||||
import { EmptyThreatDetailsView } from './empty_threat_details_view';
|
||||
|
||||
const ThreatDetailsDescription: React.FC<ThreatDetailsRow['description']> = ({
|
||||
fieldName,
|
||||
value,
|
||||
}) => (
|
||||
<EuiToolTip
|
||||
data-test-subj="message-tool-tip"
|
||||
content={
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>{fieldName}</span>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
>
|
||||
}) => {
|
||||
const tooltipChild = [INDICATOR_EVENT_URL, INDICATOR_REFERENCE].some(
|
||||
(field) => field === fieldName
|
||||
) ? (
|
||||
<EuiLink href={value} target="_blank">
|
||||
{value}
|
||||
</EuiLink>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
</EuiToolTip>
|
||||
);
|
||||
|
||||
const getSummaryRowsArray = ({
|
||||
data,
|
||||
}: {
|
||||
data: TimelineEventsDetailsItem[];
|
||||
}): ThreatDetailsRow[][] => {
|
||||
if (!data) return [[]];
|
||||
const threatInfo = data.find(
|
||||
({ field, originalValue }) => field === INDICATOR_DESTINATION_PATH && originalValue
|
||||
);
|
||||
if (!threatInfo) return [[]];
|
||||
const { originalValue } = threatInfo;
|
||||
const values = Array.isArray(originalValue) ? originalValue : [originalValue];
|
||||
return values.map((value) =>
|
||||
getDataFromSourceHits(JSON.parse(value)).map((threatInfoItem) => ({
|
||||
title: threatInfoItem.field.replace(`${INDICATOR_DESTINATION_PATH}.`, ''),
|
||||
description: { fieldName: threatInfoItem.field, value: threatInfoItem.originalValue },
|
||||
}))
|
||||
return (
|
||||
<EuiToolTip
|
||||
data-test-subj="message-tool-tip"
|
||||
content={
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>{fieldName}</span>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
>
|
||||
{tooltipChild}
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -62,17 +61,51 @@ const summaryColumns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns
|
|||
ThreatDetailsDescription
|
||||
);
|
||||
|
||||
const getISOStringFromThreatDataItem = (threatDataItem: TimelineEventsDetailsItem[]) => {
|
||||
const firstSeen = threatDataItem.find(
|
||||
(item: TimelineEventsDetailsItem) => item.field === FIRSTSEEN
|
||||
);
|
||||
if (firstSeen) {
|
||||
const { originalValue } = firstSeen;
|
||||
const firstSeenValue = Array.isArray(originalValue) ? originalValue[0] : originalValue;
|
||||
if (!Number.isNaN(Date.parse(firstSeenValue))) {
|
||||
return firstSeenValue;
|
||||
}
|
||||
}
|
||||
return new Date(-1).toString();
|
||||
};
|
||||
|
||||
const getThreatDetailsRowsArray = (threatData: TimelineEventsDetailsItem[][]) =>
|
||||
threatData
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Date.parse(getISOStringFromThreatDataItem(b)) -
|
||||
Date.parse(getISOStringFromThreatDataItem(a))
|
||||
)
|
||||
.map((items) =>
|
||||
items.map(({ field, originalValue }) => ({
|
||||
title: field,
|
||||
description: {
|
||||
fieldName: `${INDICATOR_DESTINATION_PATH}.${field}`,
|
||||
value: Array.isArray(originalValue) ? originalValue[0] : originalValue,
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
const ThreatDetailsViewComponent: React.FC<{
|
||||
data: TimelineEventsDetailsItem[];
|
||||
}> = ({ data }) => {
|
||||
const summaryRowsArray = useMemo(() => getSummaryRowsArray({ data }), [data]);
|
||||
return (
|
||||
threatData: TimelineEventsDetailsItem[][];
|
||||
}> = ({ threatData }) => {
|
||||
const threatDetailsRowsArray = getThreatDetailsRowsArray(threatData);
|
||||
return isEmpty(threatDetailsRowsArray) || isEmpty(threatDetailsRowsArray[0]) ? (
|
||||
<EmptyThreatDetailsView />
|
||||
) : (
|
||||
<>
|
||||
{summaryRowsArray.map((summaryRows, index, arr) => {
|
||||
{threatDetailsRowsArray.map((summaryRows, index, arr) => {
|
||||
const key = summaryRows.find((threat) => threat.title === 'matched.id')?.description
|
||||
.value[0];
|
||||
return (
|
||||
<div key={key}>
|
||||
<div key={`${key}-${index}`}>
|
||||
{index === 0 && <EuiSpacer size="l" />}
|
||||
<SummaryView
|
||||
summaryColumns={summaryColumns}
|
||||
summaryRows={summaryRows}
|
||||
|
|
|
@ -8,11 +8,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import { ThreatSummaryView } from './threat_summary_view';
|
||||
import { mockAlertDetailsData } from './__mocks__';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
|
||||
import { TestProviders } from '../../mock';
|
||||
import { useMountAppended } from '../../utils/use_mount_appended';
|
||||
import { mockAlertDetailsData } from './__mocks__';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
|
||||
jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async', () => {
|
||||
return {
|
||||
|
|
|
@ -5,16 +5,39 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiBasicTableColumn, EuiSpacer } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field';
|
||||
import { BrowserFields } from '../../../../common/search_strategy/index_fields';
|
||||
import * as i18n from './translations';
|
||||
import { SummaryView } from './summary_view';
|
||||
import { getSummaryColumns, SummaryRow, ThreatSummaryRow } from './helpers';
|
||||
import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
|
||||
import { SORTED_THREAT_SUMMARY_FIELDS } from '../../../../common/cti/constants';
|
||||
import { INDICATOR_DESTINATION_PATH } from '../../../../common/constants';
|
||||
|
||||
const getThreatSummaryRows = (
|
||||
data: TimelineEventsDetailsItem[],
|
||||
timelineId: string,
|
||||
eventId: string
|
||||
) =>
|
||||
SORTED_THREAT_SUMMARY_FIELDS.map((threatSummaryField) => {
|
||||
const item = data.find(({ field }) => field === threatSummaryField);
|
||||
if (item) {
|
||||
const { field, originalValue } = item;
|
||||
return {
|
||||
title: field.replace(`${INDICATOR_DESTINATION_PATH}.`, ''),
|
||||
description: {
|
||||
values: Array.isArray(originalValue) ? originalValue : [originalValue],
|
||||
contextId: timelineId,
|
||||
eventId,
|
||||
fieldName: field,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((item: ThreatSummaryRow | null): item is ThreatSummaryRow => !!item);
|
||||
|
||||
const getDescription = ({
|
||||
contextId,
|
||||
eventId,
|
||||
|
@ -34,56 +57,22 @@ const getDescription = ({
|
|||
</>
|
||||
);
|
||||
|
||||
const getSummaryRows = ({
|
||||
data,
|
||||
timelineId: contextId,
|
||||
eventId,
|
||||
}: {
|
||||
data: TimelineEventsDetailsItem[];
|
||||
browserFields?: BrowserFields;
|
||||
timelineId: string;
|
||||
eventId: string;
|
||||
}) => {
|
||||
if (!data) return [];
|
||||
return data.reduce<SummaryRow[]>((acc, { field, originalValue }) => {
|
||||
if (field.startsWith(`${INDICATOR_DESTINATION_PATH}.`) && originalValue) {
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
title: field.replace(`${INDICATOR_DESTINATION_PATH}.`, ''),
|
||||
description: {
|
||||
values: Array.isArray(originalValue) ? originalValue : [originalValue],
|
||||
contextId,
|
||||
eventId,
|
||||
fieldName: field,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const summaryColumns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns(getDescription);
|
||||
|
||||
const ThreatSummaryViewComponent: React.FC<{
|
||||
data: TimelineEventsDetailsItem[];
|
||||
eventId: string;
|
||||
timelineId: string;
|
||||
}> = ({ data, eventId, timelineId }) => {
|
||||
const summaryRows = useMemo(() => getSummaryRows({ data, eventId, timelineId }), [
|
||||
data,
|
||||
eventId,
|
||||
timelineId,
|
||||
]);
|
||||
|
||||
return (
|
||||
eventId: string;
|
||||
}> = ({ data, timelineId, eventId }) => (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<SummaryView
|
||||
title={i18n.THREAT_SUMMARY}
|
||||
summaryColumns={summaryColumns}
|
||||
summaryRows={summaryRows}
|
||||
summaryRows={getThreatSummaryRows(data, timelineId, eventId)}
|
||||
dataTestSubj="threat-summary-view"
|
||||
/>
|
||||
);
|
||||
};
|
||||
</>
|
||||
);
|
||||
|
||||
export const ThreatSummaryView = React.memo(ThreatSummaryViewComponent);
|
||||
|
|
|
@ -11,12 +11,35 @@ export const SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.summa
|
|||
defaultMessage: 'Summary',
|
||||
});
|
||||
|
||||
export const ALERT_SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.alertSummary', {
|
||||
defaultMessage: 'Alert Summary',
|
||||
});
|
||||
|
||||
export const THREAT_INTEL = i18n.translate('xpack.securitySolution.alertDetails.threatIntel', {
|
||||
defaultMessage: 'Threat Intel',
|
||||
});
|
||||
|
||||
export const THREAT_SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.threatSummary', {
|
||||
defaultMessage: 'Threat Summary',
|
||||
});
|
||||
|
||||
export const THREAT_DETAILS = i18n.translate('xpack.securitySolution.alertDetails.threatDetails', {
|
||||
defaultMessage: 'Threat Details',
|
||||
export const NO_ENRICHMENT_FOUND = i18n.translate(
|
||||
'xpack.securitySolution.alertDetails.noEnrichmentFound',
|
||||
{
|
||||
defaultMessage: 'No Threat Intel Enrichment Found',
|
||||
}
|
||||
);
|
||||
|
||||
export const IF_CTI_NOT_ENABLED = i18n.translate(
|
||||
'xpack.securitySolution.alertDetails.ifCtiNotEnabled',
|
||||
{
|
||||
defaultMessage:
|
||||
"If you haven't enabled any threat intelligence sources and want to learn more about this capability, ",
|
||||
}
|
||||
);
|
||||
|
||||
export const CHECK_DOCS = i18n.translate('xpack.securitySolution.alertDetails.checkDocs', {
|
||||
defaultMessage: 'please check out our documentation.',
|
||||
});
|
||||
|
||||
export const INVESTIGATION_GUIDE = i18n.translate(
|
||||
|
|
|
@ -18,17 +18,12 @@ import {
|
|||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { TimelineTabs } from '../../../../../common/types/timeline';
|
||||
import { BrowserFields } from '../../../../common/containers/source';
|
||||
import {
|
||||
EventDetails,
|
||||
EventsViewType,
|
||||
EventView,
|
||||
ThreatView,
|
||||
} from '../../../../common/components/event_details/event_details';
|
||||
import { EventDetails } from '../../../../common/components/event_details/event_details';
|
||||
import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
|
||||
import { LineClamp } from '../../../../common/components/line_clamp';
|
||||
import * as i18n from './translations';
|
||||
|
@ -88,9 +83,6 @@ ExpandableEventTitle.displayName = 'ExpandableEventTitle';
|
|||
|
||||
export const ExpandableEvent = React.memo<Props>(
|
||||
({ browserFields, event, timelineId, timelineTabType, isAlert, loading, detailsData }) => {
|
||||
const [eventView, setEventView] = useState<EventView>(EventsViewType.summaryView);
|
||||
const [threatView, setThreatView] = useState<ThreatView>(EventsViewType.threatSummaryView);
|
||||
|
||||
const message = useMemo(() => {
|
||||
if (detailsData) {
|
||||
const messageField = find({ category: 'base', field: 'message' }, detailsData) as
|
||||
|
@ -133,12 +125,8 @@ export const ExpandableEvent = React.memo<Props>(
|
|||
data={detailsData!}
|
||||
id={event.eventId!}
|
||||
isAlert={isAlert}
|
||||
onThreatViewSelected={setThreatView}
|
||||
onEventViewSelected={setEventView}
|
||||
threatView={threatView}
|
||||
timelineId={timelineId}
|
||||
timelineTabType={timelineTabType}
|
||||
eventView={eventView}
|
||||
/>
|
||||
</StyledEuiFlexItem>
|
||||
</StyledFlexGroup>
|
||||
|
|
Loading…
Reference in a new issue