Skip to content

Commit

Permalink
[Security-Solution] Adds Threat Summary and Threat Details tabs to Al…
Browse files Browse the repository at this point in the history
…ert Side Panel (elastic#909) (elastic#95604) (elastic#96879)

[Security Solution] Adds Threat Summary and Threat Info views to Alert Side Panel (elastic/security-team/909)

Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
ecezalp and kibanamachine authored Apr 13, 2021
1 parent f3fafa4 commit 0a6fd4e
Show file tree
Hide file tree
Showing 29 changed files with 731 additions and 283 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
* 2.0.
*/

import { EventHit, EventSource } from '../../../../../../common/search_strategy';
import { getDataFromFieldsHits, getDataFromSourceHits, getDataSafety } from './helpers';
import { eventDetailsFormattedFields, eventHit } from '../mocks';
import { EventHit, EventSource } from '../search_strategy';
import { getDataFromFieldsHits, getDataFromSourceHits, getDataSafety } from './field_formatters';
import { eventDetailsFormattedFields, eventHit } from './mock_event_details';

describe('Events Details Helpers', () => {
const fields: EventHit['fields'] = eventHit.fields;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@

import { get, isEmpty, isNumber, isObject, isString } from 'lodash/fp';

import {
EventHit,
EventSource,
TimelineEventsDetailsItem,
} from '../../../../../../common/search_strategy';
import { toObjectArrayOfStrings, toStringArray } from '../../../../helpers/to_array';
import { EventHit, EventSource, TimelineEventsDetailsItem } from '../search_strategy';
import { toObjectArrayOfStrings, toStringArray } from './to_array';

export const baseCategoryFields = ['@timestamp', 'labels', 'message', 'tags'];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -655,4 +655,16 @@ export const mockAlertDetailsData = [
values: ['7.10.0'],
originalValue: ['7.10.0'],
},
{
category: 'threat',
field: 'threat.indicator',
values: [`{"first_seen":"2021-03-25T18:17:00.000Z"}`],
originalValue: [`{"first_seen":"2021-03-25T18:17:00.000Z"}`],
},
{
category: 'threat',
field: 'threat.indicator.matched',
values: `["file", "url"]`,
originalValue: ['file', 'url'],
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import React from 'react';
import { waitFor } from '@testing-library/react';

import { SummaryViewComponent } from './summary_view';
import { AlertSummaryView } from './alert_summary_view';
import { mockAlertDetailsData } from './__mocks__';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async';
Expand All @@ -30,7 +30,7 @@ const props = {
timelineId: 'detections-page',
};

describe('SummaryViewComponent', () => {
describe('AlertSummaryView', () => {
const mount = useMountAppended();

beforeEach(() => {
Expand All @@ -44,7 +44,7 @@ describe('SummaryViewComponent', () => {
test('render correct items', () => {
const wrapper = mount(
<TestProviders>
<SummaryViewComponent {...props} />
<AlertSummaryView {...props} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="summary-view"]').exists()).toEqual(true);
Expand All @@ -53,7 +53,7 @@ describe('SummaryViewComponent', () => {
test('render investigation guide', async () => {
const wrapper = mount(
<TestProviders>
<SummaryViewComponent {...props} />
<AlertSummaryView {...props} />
</TestProviders>
);
await waitFor(() => {
Expand All @@ -69,7 +69,7 @@ describe('SummaryViewComponent', () => {
});
const wrapper = mount(
<TestProviders>
<SummaryViewComponent {...props} />
<AlertSummaryView {...props} />
</TestProviders>
);
await waitFor(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* 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 {
EuiBasicTableColumn,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
} from '@elastic/eui';
import { get, getOr } from 'lodash/fp';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { BrowserFields } from '../../../../common/search_strategy/index_fields';
import {
ALERTS_HEADERS_RISK_SCORE,
ALERTS_HEADERS_RULE,
ALERTS_HEADERS_SEVERITY,
ALERTS_HEADERS_THRESHOLD_CARDINALITY,
ALERTS_HEADERS_THRESHOLD_COUNT,
ALERTS_HEADERS_THRESHOLD_TERMS,
} from '../../../detections/components/alerts_table/translations';
import {
IP_FIELD_TYPE,
SIGNAL_RULE_NAME_FIELD_NAME,
} from '../../../timelines/components/timeline/body/renderers/constants';
import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../network/components/ip';
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)`
padding: 24px 4px 4px;
`;

const fields = [
{ id: 'signal.status' },
{ id: '@timestamp' },
{
id: SIGNAL_RULE_NAME_FIELD_NAME,
linkField: 'signal.rule.id',
label: ALERTS_HEADERS_RULE,
},
{ id: 'signal.rule.severity', label: ALERTS_HEADERS_SEVERITY },
{ id: 'signal.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE },
{ id: 'host.name' },
{ id: 'user.name' },
{ id: SOURCE_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE },
{ id: DESTINATION_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE },
{ id: 'signal.threshold_result.count', label: ALERTS_HEADERS_THRESHOLD_COUNT },
{ id: 'signal.threshold_result.terms', label: ALERTS_HEADERS_THRESHOLD_TERMS },
{ id: 'signal.threshold_result.cardinality', label: ALERTS_HEADERS_THRESHOLD_CARDINALITY },
];

const getDescription = ({
contextId,
eventId,
fieldName,
value,
fieldType = '',
linkValue,
}: AlertSummaryRow['description']) => (
<FormattedFieldValue
contextId={`alert-details-value-formatted-field-value-${contextId}-${eventId}-${fieldName}-${value}`}
eventId={eventId}
fieldName={fieldName}
fieldType={fieldType}
value={value}
linkValue={linkValue}
/>
);

const getSummaryRows = ({
data,
browserFields,
timelineId,
eventId,
}: {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
timelineId: string;
eventId: string;
}) => {
return data != null
? fields.reduce<SummaryRow[]>((acc, item) => {
const field = data.find((d) => d.field === item.id);
if (!field) {
return acc;
}
const linkValueField =
item.linkField != null && data.find((d) => d.field === item.linkField);
const linkValue = getOr(null, 'originalValue.0', linkValueField);
const value = getOr(null, 'originalValue.0', field);
const category = field.category;
const fieldType = get(`${category}.fields.${field.field}.type`, browserFields) as string;
const description = {
contextId: timelineId,
eventId,
fieldName: item.id,
value,
fieldType: item.fieldType ?? fieldType,
linkValue: linkValue ?? undefined,
};

if (item.id === 'signal.threshold_result.terms') {
try {
const terms = getOr(null, 'originalValue', field);
const parsedValue = terms.map((term: string) => JSON.parse(term));
const thresholdTerms = (parsedValue ?? []).map(
(entry: { field: string; value: string }) => {
return {
title: `${entry.field} [threshold]`,
description: {
...description,
value: entry.value,
},
};
}
);
return [...acc, ...thresholdTerms];
} catch (err) {
return acc;
}
}

if (item.id === 'signal.threshold_result.cardinality') {
try {
const parsedValue = JSON.parse(value);
return [
...acc,
{
title: ALERTS_HEADERS_THRESHOLD_CARDINALITY,
description: {
...description,
value: `count(${parsedValue.field}) == ${parsedValue.value}`,
},
},
];
} catch (err) {
return acc;
}
}

return [
...acc,
{
title: item.label ?? item.id,
description,
},
];
}, [])
: [];
};

const summaryColumns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns(getDescription);

const AlertSummaryViewComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
eventId: string;
timelineId: string;
}> = ({ browserFields, data, eventId, timelineId }) => {
const summaryRows = useMemo(() => getSummaryRows({ browserFields, data, eventId, timelineId }), [
browserFields,
data,
eventId,
timelineId,
]);

const ruleId = useMemo(() => {
const item = data.find((d) => d.field === 'signal.rule.id');
return Array.isArray(item?.originalValue)
? item?.originalValue[0]
: item?.originalValue ?? null;
}, [data]);
const { rule: maybeRule } = useRuleAsync(ruleId);

return (
<>
<SummaryView summaryColumns={summaryColumns} summaryRows={summaryRows} />
{maybeRule?.note && (
<StyledEuiDescriptionList data-test-subj={`summary-view-guide`} compressed>
<EuiDescriptionListTitle>{i18n.INVESTIGATION_GUIDE}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<LineClamp content={maybeRule?.note} />
</EuiDescriptionListDescription>
</StyledEuiDescriptionList>
)}
</>
);
};

export const AlertSummaryView = React.memo(AlertSummaryViewComponent);
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import '../../mock/match_media';
import '../../mock/react_beautiful_dnd';
import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock';

import { EventDetails, EventsViewType } from './event_details';
import { EventDetails, EventsViewType, EventView, ThreatView } from './event_details';
import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { mockAlertDetailsData } from './__mocks__';
Expand All @@ -28,10 +28,12 @@ describe('EventDetails', () => {
data: mockDetailItemData,
id: mockDetailItemDataId,
isAlert: false,
onViewSelected: jest.fn(),
onEventViewSelected: jest.fn(),
onThreatViewSelected: jest.fn(),
timelineTabType: TimelineTabs.query,
timelineId: 'test',
view: EventsViewType.summaryView,
eventView: EventsViewType.summaryView as EventView,
threatView: EventsViewType.threatSummaryView as ThreatView,
};

const alertsProps = {
Expand Down Expand Up @@ -97,4 +99,27 @@ 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');
});
});
});
Loading

0 comments on commit 0a6fd4e

Please sign in to comment.