Add Host Risk metadata data to alerts flyout (#113274) (#114238)

* Filter out empty values from alert flyout overview

* Add Host Risk metadata data to alerts flyout

* Add feature flag to host risk data query

* Swap investigation guide and enrichment data places in the UI

* Migrate alert_summary_view.test to react testing library

* Refactor threat summary by extracting components and renaming

Co-authored-by: Pablo Machado <pablo.nevesmachado@elastic.co>
This commit is contained in:
Kibana Machine 2021-10-07 07:36:43 -04:00 committed by GitHub
parent c07e45232e
commit bbaec75321
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1216 additions and 1011 deletions

View file

@ -333,7 +333,7 @@ export const ELASTIC_NAME = 'estc';
export const METADATA_TRANSFORM_STATS_URL = `/api/transform/transforms/${METADATA_TRANSFORMS_PATTERN}/_stats`;
export const RISKY_HOSTS_INDEX = 'ml_host_risk_score_latest';
export const HOST_RISK_SCORES_INDEX = 'ml_host_risk_score_latest';
export const TRANSFORM_STATES = {
ABORTING: 'aborting',

View file

@ -11,7 +11,7 @@ export * from './common';
export * from './details';
export * from './first_last_seen';
export * from './kpi';
export * from './risky_hosts';
export * from './risk_score';
export * from './overview';
export * from './uncommon_processes';
@ -23,6 +23,6 @@ export enum HostsQueries {
hosts = 'hosts',
hostsEntities = 'hostsEntities',
overview = 'overviewHost',
riskyHosts = 'riskyHosts',
hostsRiskScore = 'hostsRiskScore',
uncommonProcesses = 'uncommonProcesses',
}

View file

@ -0,0 +1,32 @@
/*
* 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 { FactoryQueryTypes } from '../..';
import {
IEsSearchRequest,
IEsSearchResponse,
} from '../../../../../../../../src/plugins/data/common';
import { Inspect, Maybe, TimerangeInput } from '../../../common';
export interface HostsRiskScoreRequestOptions extends IEsSearchRequest {
defaultIndex: string[];
factoryQueryType?: FactoryQueryTypes;
hostName?: string;
timerange?: TimerangeInput;
}
export interface HostsRiskScoreStrategyResponse extends IEsSearchResponse {
inspect?: Maybe<Inspect>;
}
export interface HostsRiskScore {
host: {
name: string;
};
risk_score: number;
risk: string;
}

View file

@ -1,15 +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 { Inspect, Maybe, RequestBasicOptions } from '../../..';
import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common';
export type HostsRiskyHostsRequestOptions = RequestBasicOptions;
export interface HostsRiskyHostsStrategyResponse extends IEsSearchResponse {
inspect?: Maybe<Inspect>;
}

View file

@ -28,8 +28,8 @@ import {
HostsKpiUniqueIpsStrategyResponse,
HostsKpiUniqueIpsRequestOptions,
HostFirstLastSeenRequestOptions,
HostsRiskyHostsStrategyResponse,
HostsRiskyHostsRequestOptions,
HostsRiskScoreStrategyResponse,
HostsRiskScoreRequestOptions,
} from './hosts';
import {
NetworkQueries,
@ -126,8 +126,8 @@ export type StrategyResponseType<T extends FactoryQueryTypes> = T extends HostsQ
? HostDetailsStrategyResponse
: T extends UebaQueries.riskScore
? RiskScoreStrategyResponse
: T extends HostsQueries.riskyHosts
? HostsRiskyHostsStrategyResponse
: T extends HostsQueries.hostsRiskScore
? HostsRiskScoreStrategyResponse
: T extends UebaQueries.hostRules
? HostRulesStrategyResponse
: T extends UebaQueries.userRules
@ -182,8 +182,8 @@ export type StrategyResponseType<T extends FactoryQueryTypes> = T extends HostsQ
export type StrategyRequestType<T extends FactoryQueryTypes> = T extends HostsQueries.hosts
? HostsRequestOptions
: T extends HostsQueries.riskyHosts
? HostsRiskyHostsRequestOptions
: T extends HostsQueries.hostsRiskScore
? HostsRiskScoreRequestOptions
: T extends HostsQueries.details
? HostDetailsRequestOptions
: T extends HostsQueries.overview

View file

@ -1,10 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AlertSummaryView Behavior event code renders additional summary rows 1`] = `
.c1 {
line-height: 1.7rem;
}
.c0 .euiTableHeaderCell,
.c0 .euiTableRowCell {
border: none;
@ -24,6 +20,10 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
opacity: 1;
}
.c1 {
line-height: 1.7rem;
}
.c2 {
min-width: 138px;
padding: 0 8px;
@ -53,10 +53,6 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
opacity: 1;
}
.c3 {
padding: 4px 0;
}
<div
class="euiBasicTable c0"
data-test-subj="summary-view"
@ -141,7 +137,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.status"
>
<div
class="euiText euiText--extraSmall"
>
@ -205,7 +203,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-@timestamp"
>
<div
class="eventFieldsTable__fieldValue"
>
@ -273,7 +273,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.rule.name"
>
<div
class="euiText euiText--extraSmall"
>
@ -337,7 +339,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.rule.severity"
>
<div
class="euiText euiText--extraSmall"
>
@ -401,7 +405,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.rule.risk_score"
>
<div
class="euiText euiText--extraSmall"
>
@ -465,7 +471,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-host.name"
>
<div
class="euiText euiText--extraSmall"
>
@ -529,7 +537,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-user.name"
>
<div
class="euiText euiText--extraSmall"
>
@ -593,7 +603,9 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-source.ip"
>
<div
class="eventFieldsTable__fieldValue"
>
@ -644,161 +656,6 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
destination.ip
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Threshold Count
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Threshold Terms
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Threshold Cardinality
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Rule description
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@ -806,10 +663,6 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
`;
exports[`AlertSummaryView Memory event code renders additional summary rows 1`] = `
.c1 {
line-height: 1.7rem;
}
.c0 .euiTableHeaderCell,
.c0 .euiTableRowCell {
border: none;
@ -829,6 +682,10 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
opacity: 1;
}
.c1 {
line-height: 1.7rem;
}
.c2 {
min-width: 138px;
padding: 0 8px;
@ -858,10 +715,6 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
opacity: 1;
}
.c3 {
padding: 4px 0;
}
<div
class="euiBasicTable c0"
data-test-subj="summary-view"
@ -946,7 +799,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.status"
>
<div
class="euiText euiText--extraSmall"
>
@ -1010,7 +865,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-@timestamp"
>
<div
class="eventFieldsTable__fieldValue"
>
@ -1078,7 +935,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.rule.name"
>
<div
class="euiText euiText--extraSmall"
>
@ -1142,7 +1001,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.rule.severity"
>
<div
class="euiText euiText--extraSmall"
>
@ -1206,7 +1067,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-signal.rule.risk_score"
>
<div
class="euiText euiText--extraSmall"
>
@ -1270,7 +1133,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-host.name"
>
<div
class="euiText euiText--extraSmall"
>
@ -1334,7 +1199,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-user.name"
>
<div
class="euiText euiText--extraSmall"
>
@ -1398,7 +1265,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div>
<div
data-test-subj="event-field-source.ip"
>
<div
class="eventFieldsTable__fieldValue"
>
@ -1449,192 +1318,6 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
destination.ip
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Threshold Count
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Threshold Terms
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Threshold Cardinality
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Rule name
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
style="width: 220px;"
>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<h5
class="c1 euiTitle euiTitle--xxxsmall"
>
Import Hash
</h5>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent"
>
<div
class="c3"
>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { waitFor } from '@testing-library/react';
import { waitFor, render } from '@testing-library/react';
import { AlertSummaryView } from './alert_summary_view';
import { mockAlertDetailsData } from './__mocks__';
@ -15,7 +15,6 @@ import { useRuleWithFallback } from '../../../detections/containers/detection_en
import { TestProviders, TestProvidersComponent } from '../../mock';
import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
jest.mock('../../lib/kibana');
@ -33,8 +32,6 @@ const props = {
};
describe('AlertSummaryView', () => {
const mount = useMountAppended();
beforeEach(() => {
jest.clearAllMocks();
(useRuleWithFallback as jest.Mock).mockReturnValue({
@ -44,23 +41,12 @@ describe('AlertSummaryView', () => {
});
});
test('render correct items', () => {
const wrapper = mount(
const { getByTestId } = render(
<TestProviders>
<AlertSummaryView {...props} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="summary-view"]').exists()).toEqual(true);
});
test('render investigation guide', async () => {
const wrapper = mount(
<TestProviders>
<AlertSummaryView {...props} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(true);
});
expect(getByTestId('summary-view')).toBeInTheDocument();
});
test("render no investigation guide if it doesn't exist", async () => {
@ -69,13 +55,13 @@ describe('AlertSummaryView', () => {
note: null,
},
});
const wrapper = mount(
const { queryByTestId } = render(
<TestProviders>
<AlertSummaryView {...props} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(false);
expect(queryByTestId('summary-view-guide')).not.toBeInTheDocument();
});
});
test('Memory event code renders additional summary rows', () => {
@ -93,12 +79,12 @@ describe('AlertSummaryView', () => {
return item;
}) as TimelineEventsDetailsItem[],
};
const wrapper = mount(
const { container } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
expect(wrapper.find('div[data-test-subj="summary-view"]').render()).toMatchSnapshot();
expect(container.querySelector('div[data-test-subj="summary-view"]')).toMatchSnapshot();
});
test('Behavior event code renders additional summary rows', () => {
const renderProps = {
@ -115,11 +101,36 @@ describe('AlertSummaryView', () => {
return item;
}) as TimelineEventsDetailsItem[],
};
const wrapper = mount(
const { container } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
expect(wrapper.find('div[data-test-subj="summary-view"]').render()).toMatchSnapshot();
expect(container.querySelector('div[data-test-subj="summary-view"]')).toMatchSnapshot();
});
test("doesn't render empty fields", () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
if (item.category === 'signal' && item.field === 'signal.rule.name') {
return {
category: 'signal',
field: 'signal.rule.name',
values: undefined,
originalValue: undefined,
};
}
return item;
}) as TimelineEventsDetailsItem[],
};
const { queryByTestId } = render(
<TestProviders>
<AlertSummaryView {...renderProps} />
</TestProviders>
);
expect(queryByTestId('event-field-signal.rule.name')).not.toBeInTheDocument();
});
});

View file

@ -5,113 +5,18 @@
* 2.0.
*/
import { EuiBasicTableColumn, EuiSpacer, EuiHorizontalRule, EuiTitle, EuiText } from '@elastic/eui';
import { get, getOr, find, isEmpty } from 'lodash/fp';
import { EuiBasicTableColumn, EuiSpacer } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
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,
ALERTS_HEADERS_RULE_NAME,
SIGNAL_STATUS,
ALERTS_HEADERS_TARGET_IMPORT_HASH,
TIMESTAMP,
ALERTS_HEADERS_RULE_DESCRIPTION,
} from '../../../detections/components/alerts_table/translations';
import {
AGENT_STATUS_FIELD_NAME,
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 { useRuleWithFallback } from '../../../detections/containers/detection_engine/rules/use_rule_with_fallback';
import { MarkdownRenderer } from '../markdown_editor';
import { LineClamp } from '../line_clamp';
import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check';
import { getEmptyValue } from '../empty_value';
import { ActionCell } from './table/action_cell';
import { FieldValueCell } from './table/field_value_cell';
import { TimelineEventsDetailsItem } from '../../../../common';
import { EventCode } from '../../../../common/ecs/event';
export const Indent = styled.div`
padding: 0 8px;
word-break: break-word;
`;
const StyledEmptyComponent = styled.div`
padding: ${(props) => `${props.theme.eui.paddingSizes.xs} 0`};
`;
interface EventSummaryField {
id: string;
label?: string;
linkField?: string;
fieldType?: string;
overrideField?: string;
}
const defaultDisplayFields: EventSummaryField[] = [
{ id: 'signal.status', label: SIGNAL_STATUS },
{ id: '@timestamp', label: 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: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS },
{ 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 processCategoryFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'process.name' },
{ id: 'process.parent.name' },
{ id: 'process.args' },
];
const networkCategoryFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'destination.address' },
{ id: 'destination.port' },
{ id: 'process.name' },
];
const memoryShellCodeAlertFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'rule.name', label: ALERTS_HEADERS_RULE_NAME },
{
id: 'Target.process.thread.Ext.start_address_details.memory_pe.imphash',
label: ALERTS_HEADERS_TARGET_IMPORT_HASH,
},
];
const behaviorAlertFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'rule.description', label: ALERTS_HEADERS_RULE_DESCRIPTION },
];
const memorySignatureAlertFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'rule.name', label: ALERTS_HEADERS_RULE_NAME },
];
import { getSummaryRows } from './get_alert_summary_rows';
const getDescription = ({
data,
@ -121,187 +26,28 @@ const getDescription = ({
linkValue,
timelineId,
values,
}: AlertSummaryRow['description']) => {
if (isEmpty(values)) {
return <StyledEmptyComponent>{getEmptyValue()}</StyledEmptyComponent>;
}
return (
<>
<FieldValueCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
isDraggable={isDraggable}
values={values}
/>
<ActionCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
timelineId={timelineId}
values={values}
/>
</>
);
};
function getEventFieldsToDisplay({
eventCategory,
eventCode,
}: {
eventCategory: string;
eventCode?: string;
}): EventSummaryField[] {
switch (eventCode) {
// memory protection fields
case EventCode.SHELLCODE_THREAD:
return memoryShellCodeAlertFields;
case EventCode.MEMORY_SIGNATURE:
return memorySignatureAlertFields;
case EventCode.BEHAVIOR:
return behaviorAlertFields;
}
switch (eventCategory) {
case 'network':
return networkCategoryFields;
case 'process':
return processCategoryFields;
}
return defaultDisplayFields;
}
export const getSummaryRows = ({
data,
browserFields,
timelineId,
eventId,
isDraggable = false,
}: {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
timelineId: string;
eventId: string;
isDraggable?: boolean;
}) => {
const eventCategoryField = find({ category: 'event', field: 'event.category' }, data);
const eventCategory = Array.isArray(eventCategoryField?.originalValue)
? eventCategoryField?.originalValue[0]
: eventCategoryField?.originalValue;
const eventCodeField = find({ category: 'event', field: 'event.code' }, data);
const eventCode = Array.isArray(eventCodeField?.originalValue)
? eventCodeField?.originalValue?.[0]
: eventCodeField?.originalValue;
const tableFields = getEventFieldsToDisplay({ eventCategory, eventCode });
return data != null
? tableFields.reduce<SummaryRow[]>((acc, item) => {
const initialDescription = {
contextId: timelineId,
eventId,
isDraggable,
value: null,
fieldType: 'string',
linkValue: undefined,
timelineId,
};
const field = data.find((d) => d.field === item.id);
if (!field) {
return [
...acc,
{
title: item.label ?? item.id,
description: initialDescription,
},
];
}
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 fieldName = field.field ?? '';
const browserField = get([category, 'fields', fieldName], browserFields);
const description = {
...initialDescription,
data: {
field: field.field,
format: browserField?.format ?? '',
type: browserField?.type ?? '',
isObjectArray: field.isObjectArray,
...(item.overrideField ? { field: item.overrideField } : {}),
},
values: field.values,
linkValue: linkValue ?? undefined,
fieldFromBrowserField: browserField,
};
if (item.id === 'agent.id' && !isAlertFromEndpointEvent({ data })) {
return acc;
}
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,
values: [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,
values: [`count(${parsedValue.field}) == ${parsedValue.value}`],
},
},
];
} catch (err) {
return acc;
}
}
return [
...acc,
{
title: item.label ?? item.id,
description,
},
];
}, [])
: [];
};
}: AlertSummaryRow['description']) => (
<>
<FieldValueCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
isDraggable={isDraggable}
values={values}
/>
<ActionCell
contextId={timelineId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
timelineId={timelineId}
values={values}
/>
</>
);
const summaryColumns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns(getDescription);
@ -318,33 +64,10 @@ const AlertSummaryViewComponent: React.FC<{
[browserFields, data, eventId, isDraggable, 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 } = useRuleWithFallback(ruleId);
return (
<>
<EuiSpacer size="s" />
<SummaryView summaryColumns={summaryColumns} summaryRows={summaryRows} title={title} />
{maybeRule?.note && (
<>
<EuiHorizontalRule />
<EuiTitle size="xxxs" data-test-subj="summary-view-guide">
<h5>{i18n.INVESTIGATION_GUIDE}</h5>
</EuiTitle>
<EuiSpacer size="s" />
<Indent>
<EuiText size="xs">
<LineClamp lineClampHeight={4.5}>
<MarkdownRenderer>{maybeRule.note}</MarkdownRenderer>
</LineClamp>
</EuiText>
</Indent>
</>
)}
</>
);
};

View file

@ -13,8 +13,8 @@ import { isInvestigationTimeEnrichment } from './helpers';
export const getTooltipTitle = (type: string | undefined) =>
isInvestigationTimeEnrichment(type)
? i18n.INVESTIGATION_TOOLTIP_TITLE
: i18n.INDICATOR_TOOLTIP_TITLE;
? i18n.INVESTIGATION_ENRICHMENT_TITLE
: i18n.INDICATOR_ENRICHMENT_TITLE;
export const getTooltipContent = (type: string | undefined) =>
isInvestigationTimeEnrichment(type)

View file

@ -0,0 +1,189 @@
/*
* 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 from 'react';
import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { partition } from 'lodash';
import * as i18n from './translations';
import { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment } from './helpers';
import { FieldsData } from '../types';
import { ActionCell } from '../table/action_cell';
import { BrowserField, BrowserFields, TimelineEventsDetailsItem } from '../../../../../common';
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view';
export interface ThreatSummaryDescription {
browserField: BrowserField;
data: FieldsData | undefined;
eventId: string;
index: number;
provider: string | undefined;
timelineId: string;
value: string | undefined;
isDraggable?: boolean;
}
const EnrichmentFieldProvider = styled.span`
margin-left: ${({ theme }) => theme.eui.paddingSizes.xs};
white-space: nowrap;
font-style: italic;
`;
const EnrichmentDescription: React.FC<ThreatSummaryDescription> = ({
browserField,
data,
eventId,
index,
provider,
timelineId,
value,
isDraggable,
}) => {
if (!data || !value) return null;
const key = `alert-details-value-formatted-field-value-${timelineId}-${eventId}-${data.field}-${value}-${index}-${provider}`;
return (
<EuiFlexGroup key={key} direction="row" gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
<div className="eui-textBreakAll">
<FormattedFieldValue
contextId={timelineId}
eventId={key}
fieldFormat={data.format}
fieldName={data.field}
fieldType={data.type}
isDraggable={isDraggable}
isObjectArray={data.isObjectArray}
value={value}
/>
{provider && (
<EnrichmentFieldProvider>
{i18n.PROVIDER_PREPOSITION} {provider}
</EnrichmentFieldProvider>
)}
</div>
</EuiFlexItem>
<EuiFlexItem>
{value && (
<ActionCell
data={data}
contextId={timelineId}
eventId={key}
fieldFromBrowserField={browserField}
timelineId={timelineId}
values={[value]}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};
const EnrichmentSummaryComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
enrichments: CtiEnrichment[];
timelineId: string;
eventId: string;
isDraggable?: boolean;
}> = ({ browserFields, data, enrichments, timelineId, eventId, isDraggable }) => {
const parsedEnrichments = enrichments.map((enrichment, index) => {
const { field, type, provider, 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: browserField?.format ?? '',
type: browserField?.type ?? '',
isObjectArray: eventData?.isObjectArray ?? false,
};
return {
fieldsData,
type,
provider,
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, provider, browserField, value }) => (
<EnrichedDataRow
key={field}
field={field}
value={
<EnrichmentDescription
eventId={eventId}
index={index}
provider={provider}
timelineId={timelineId}
value={value}
data={fieldsData}
browserField={browserField}
isDraggable={isDraggable}
/>
}
/>
))}
</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, provider, browserField, value }) => (
<EnrichedDataRow
key={field}
field={field}
value={
<EnrichmentDescription
eventId={eventId}
index={index}
provider={provider}
timelineId={timelineId}
value={value}
data={fieldsData}
browserField={browserField}
isDraggable={isDraggable}
/>
}
/>
))}
</EuiPanel>
</EuiFlexItem>
)}
</>
);
};
export const EnrichmentSummary = React.memo(EnrichmentSummaryComponent);

View file

@ -0,0 +1,96 @@
/*
* 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 '../../../mock';
import { NO_HOST_RISK_DATA_DESCRIPTION } from './translations';
import { HostRiskSummary } from './host_risk_summary';
describe('HostRiskSummary', () => {
it('renders host risk data', () => {
const riskKeyword = 'test risk';
const hostRisk = {
loading: false,
isModuleEnabled: true,
result: [
{
host: {
name: 'test-host-name',
},
risk_score: 9999,
risk: riskKeyword,
},
],
};
const { getByText } = render(
<TestProviders>
<HostRiskSummary hostRisk={hostRisk} />
</TestProviders>
);
expect(getByText(riskKeyword)).toBeInTheDocument();
});
it('renders spinner when loading', () => {
const hostRisk = {
loading: true,
isModuleEnabled: true,
result: [],
};
const { getByTestId } = render(
<TestProviders>
<HostRiskSummary hostRisk={hostRisk} />
</TestProviders>
);
expect(getByTestId('loading')).toBeInTheDocument();
});
it('renders no host data message when module is diabled', () => {
const hostRisk = {
loading: false,
isModuleEnabled: false,
result: [
{
host: {
name: 'test-host-name',
},
risk_score: 9999,
risk: 'test-risk',
},
],
};
const { getByText } = render(
<TestProviders>
<HostRiskSummary hostRisk={hostRisk} />
</TestProviders>
);
expect(getByText(NO_HOST_RISK_DATA_DESCRIPTION)).toBeInTheDocument();
});
it('renders no host data message when there is no host data', () => {
const hostRisk = {
loading: false,
isModuleEnabled: true,
result: [],
};
const { getByText } = render(
<TestProviders>
<HostRiskSummary hostRisk={hostRisk} />
</TestProviders>
);
expect(getByText(NO_HOST_RISK_DATA_DESCRIPTION)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,61 @@
/*
* 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, EuiSpacer, EuiLink, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import * as i18n from './translations';
import { RISKY_HOSTS_DOC_LINK } from '../../../../overview/components/overview_risky_host_links/risky_hosts_disabled_module';
import { HostRisk } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score';
import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view';
const HostRiskSummaryComponent: React.FC<{
hostRisk: HostRisk;
}> = ({ hostRisk }) => (
<>
<EuiPanel hasBorder paddingSize="s" grow={false}>
<ThreatSummaryPanelHeader
title={i18n.HOST_RISK_DATA_TITLE}
toolTipContent={
<FormattedMessage
id="xpack.securitySolution.alertDetails.overview.hostDataTooltipContent"
defaultMessage="Risk classification is displayed only when available for a host. Ensure {hostsRiskScoreDocumentationLink} is enabled within your environment."
values={{
hostsRiskScoreDocumentationLink: (
<EuiLink href={RISKY_HOSTS_DOC_LINK} target="_blank">
<FormattedMessage
id="xpack.securitySolution.alertDetails.overview.hostsRiskScoreLink"
defaultMessage="Host Risk Score"
/>
</EuiLink>
),
}}
/>
}
/>
{hostRisk.loading && <EuiLoadingSpinner data-test-subj="loading" />}
{!hostRisk.loading && (!hostRisk.isModuleEnabled || hostRisk.result?.length === 0) && (
<>
<EuiSpacer size="s" />
<EuiText color="subdued" size="xs">
{i18n.NO_HOST_RISK_DATA_DESCRIPTION}
</EuiText>
</>
)}
{hostRisk.isModuleEnabled && hostRisk.result && hostRisk.result.length > 0 && (
<>
<EnrichedDataRow field={'host.risk.keyword'} value={hostRisk.result[0].risk} />
</>
)}
</EuiPanel>
</>
);
export const HostRiskSummary = React.memo(HostRiskSummaryComponent);

View file

@ -30,8 +30,8 @@ const EnrichmentSectionHeader: React.FC<{ type?: ENRICHMENT_TYPES }> = ({ type }
<EuiTitle size="xxxs">
<h5>
{type === ENRICHMENT_TYPES.IndicatorMatchRule
? i18n.INDICATOR_TOOLTIP_TITLE
: i18n.INVESTIGATION_TOOLTIP_TITLE}
? i18n.INDICATOR_ENRICHMENT_TITLE
: i18n.INVESTIGATION_ENRICHMENT_TITLE}
</h5>
</EuiTitle>
</EuiFlexItem>

View file

@ -9,28 +9,36 @@ import React from 'react';
import { ThreatSummaryView } from './threat_summary_view';
import { TestProviders } from '../../../mock';
import { useMountAppended } from '../../../utils/use_mount_appended';
import { render } from '@testing-library/react';
import { buildEventEnrichmentMock } from '../../../../../common/search_strategy/security_solution/cti/index.mock';
import { mockAlertDetailsData } from '../__mocks__';
import { TimelineEventsDetailsItem } from '../../../../../../timelines/common';
import { mockBrowserFields } from '../../../containers/source/mock';
import { mockTimelines } from '../../../../common/mock/mock_timelines_plugin';
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
timelines: { ...mockTimelines },
},
}),
}));
jest.mock('../table/action_cell');
jest.mock('../table/field_name_cell');
describe('ThreatSummaryView', () => {
const mount = useMountAppended();
const eventId = '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31';
const timelineId = 'detections-page';
const data = mockAlertDetailsData as TimelineEventsDetailsItem[];
const browserFields = mockBrowserFields;
it('renders a row for each enrichment', () => {
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 wrapper = mount(
const { getByText, getAllByTestId } = render(
<TestProviders>
<ThreatSummaryView
data={data}
@ -38,12 +46,13 @@ describe('ThreatSummaryView', () => {
enrichments={enrichments}
eventId={eventId}
timelineId={timelineId}
hostRisk={null}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="threat-summary-view"] .euiTableRow')).toHaveLength(
enrichments.length
);
expect(getByText('Enriched with Threat Intelligence')).toBeInTheDocument();
expect(getAllByTestId('EnrichedDataRow')).toHaveLength(enrichments.length);
});
});

View file

@ -6,172 +6,169 @@
*/
import styled from 'styled-components';
import { get } from 'lodash/fp';
import React, { Fragment } from 'react';
import { EuiBasicTableColumn, EuiText, EuiTitle } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import {
EuiTitle,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiPopover,
EuiButtonIcon,
EuiPopoverTitle,
EuiText,
} from '@elastic/eui';
import * as i18n from './translations';
import { Indent, StyledEuiInMemoryTable } from '../summary_view';
import { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
import { getEnrichmentIdentifiers } from './helpers';
import { EnrichmentIcon } from './enrichment_icon';
import { FieldsData } from '../types';
import { ActionCell } from '../table/action_cell';
import { BrowserField, BrowserFields, TimelineEventsDetailsItem } from '../../../../../common';
import { FieldValueCell } from '../table/field_value_cell';
export interface ThreatSummaryItem {
title: {
title: string | undefined;
type: string | undefined;
};
description: {
browserField: BrowserField;
data: FieldsData | undefined;
eventId: string;
index: number;
provider: string | undefined;
timelineId: string;
value: string | undefined;
};
import { FieldsData } from '../types';
import { BrowserField, BrowserFields, TimelineEventsDetailsItem } from '../../../../../common';
import { HostRisk } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score';
import { HostRiskSummary } from './host_risk_summary';
import { EnrichmentSummary } from './enrichment_summary';
export interface ThreatSummaryDescription {
browserField: BrowserField;
data: FieldsData | undefined;
eventId: string;
index: number;
provider: string | undefined;
timelineId: string;
value: string | undefined;
isDraggable?: boolean;
}
const RightMargin = styled.span`
margin-right: ${({ theme }) => theme.eui.paddingSizes.xs};
min-width: 30px;
const UppercaseEuiTitle = styled(EuiTitle)`
text-transform: uppercase;
`;
const EnrichmentTitle: React.FC<ThreatSummaryItem['title']> = ({ title, type }) => (
<>
<RightMargin>
<EuiTitle size="xxxs">
<h5>{title}</h5>
</EuiTitle>
</RightMargin>
<EnrichmentIcon type={type} />
</>
const ThreatSummaryPanelTitle: React.FC = ({ children }) => (
<UppercaseEuiTitle size="xxxs">
<h5>{children}</h5>
</UppercaseEuiTitle>
);
const EnrichmentDescription: React.FC<ThreatSummaryItem['description']> = ({
browserField,
data,
eventId,
index,
provider,
timelineId,
const StyledEnrichmentFieldTitle = styled(EuiTitle)`
width: 220px;
`;
const EnrichmentFieldTitle: React.FC<{
title: string | 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 | undefined; value: React.ReactNode }> = ({
field,
value,
}) => {
if (!data || !value) return null;
const key = `alert-details-value-formatted-field-value-${timelineId}-${eventId}-${data.field}-${value}-${index}-${provider}`;
}) => (
<StyledEuiFlexGroup
direction="row"
gutterSize="none"
responsive
alignItems="center"
data-test-subj="EnrichedDataRow"
>
<EuiFlexItem style={{ flexShrink: 0 }} grow={false}>
<EnrichmentFieldTitle title={field} />
</EuiFlexItem>
<EuiFlexItem>{value}</EuiFlexItem>
</StyledEuiFlexGroup>
);
export const ThreatSummaryPanelHeader: React.FC<{
title: string;
toolTipContent: React.ReactNode;
}> = ({ title, toolTipContent }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onClick = useCallback(() => {
setIsPopoverOpen(!isPopoverOpen);
}, [isPopoverOpen, setIsPopoverOpen]);
const closePopover = useCallback(() => {
setIsPopoverOpen(false);
}, [setIsPopoverOpen]);
return (
<Fragment key={key}>
<RightMargin>
<FieldValueCell
contextId={timelineId}
data={data}
eventId={key}
fieldFromBrowserField={browserField}
values={[value]}
/>
</RightMargin>
{provider && (
<>
<RightMargin>
<EuiText size="xs">
<em>{i18n.PROVIDER_PREPOSITION}</em>
</EuiText>
</RightMargin>
<RightMargin>
<EuiText grow={false} size="xs">
{provider}
</EuiText>
</RightMargin>
</>
)}
{value && (
<ActionCell
data={data}
contextId={timelineId}
eventId={key}
fieldFromBrowserField={browserField}
timelineId={timelineId}
values={[value]}
/>
)}
</Fragment>
<EuiFlexGroup direction="row" gutterSize="none" alignItems="center">
<EuiFlexItem>
<ThreatSummaryPanelTitle>{title}</ThreatSummaryPanelTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
button={
<EuiButtonIcon
color="text"
size="xs"
iconSize="m"
iconType="iInCircle"
aria-label={i18n.INFORMATION_ARIA_LABEL}
onClick={onClick}
/>
}
>
<EuiPopoverTitle>{title}</EuiPopoverTitle>
<EuiText size="s" style={{ width: '270px' }}>
{toolTipContent}
</EuiText>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const buildThreatSummaryItems = (
browserFields: BrowserFields,
data: TimelineEventsDetailsItem[],
enrichments: CtiEnrichment[],
timelineId: string,
eventId: string
) => {
return enrichments.map((enrichment, index) => {
const { field, type, value, provider } = getEnrichmentIdentifiers(enrichment);
const eventData = data.find((item) => item.field === field);
const category = eventData?.category ?? '';
const browserField = get([category, 'fields', field ?? ''], browserFields);
const fieldsData = {
field,
format: browserField?.format ?? '',
type: browserField?.type ?? '',
isObjectArray: eventData?.isObjectArray,
};
return {
title: {
title: field,
type,
},
description: {
eventId,
index,
provider,
timelineId,
value,
data: fieldsData,
browserField,
},
};
});
};
const columns: Array<EuiBasicTableColumn<ThreatSummaryItem>> = [
{
field: 'title',
truncateText: false,
render: EnrichmentTitle,
width: '220px',
name: '',
},
{
className: 'flyoutOverviewDescription',
field: 'description',
truncateText: false,
render: EnrichmentDescription,
name: '',
},
];
const ThreatSummaryViewComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
enrichments: CtiEnrichment[];
eventId: string;
timelineId: string;
}> = ({ browserFields, data, enrichments, eventId, timelineId }) => (
<Indent>
<StyledEuiInMemoryTable
columns={columns}
compressed
data-test-subj="threat-summary-view"
items={buildThreatSummaryItems(browserFields, data, enrichments, timelineId, eventId)}
/>
</Indent>
);
hostRisk: HostRisk | null;
isDraggable?: boolean;
}> = ({ browserFields, data, enrichments, eventId, timelineId, hostRisk, isDraggable }) => {
if (!hostRisk && 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 }}>
{hostRisk && (
<EuiFlexItem grow={false}>
<HostRiskSummary hostRisk={hostRisk} />
</EuiFlexItem>
)}
<EnrichmentSummary
browserFields={browserFields}
data={data}
enrichments={enrichments}
timelineId={timelineId}
eventId={eventId}
isDraggable={isDraggable}
/>
</EuiFlexGroup>
</>
);
};
export const ThreatSummaryView = React.memo(ThreatSummaryViewComponent);

View file

@ -14,20 +14,27 @@ export const PROVIDER_PREPOSITION = i18n.translate(
}
);
export const INDICATOR_TOOLTIP_TITLE = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.indicatorEnrichmentTooltipTitle',
export const INDICATOR_ENRICHMENT_TITLE = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.indicatorEnrichmentTitle',
{
defaultMessage: 'Threat Match Detected',
}
);
export const INVESTIGATION_TOOLTIP_TITLE = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentTooltipTitle',
export const INVESTIGATION_ENRICHMENT_TITLE = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentTitle',
{
defaultMessage: 'Enriched with Threat Intelligence',
}
);
export const HOST_RISK_DATA_TITLE = i18n.translate(
'xpack.securitySolution.alertDetails.overview.hostRiskDataTitle',
{
defaultMessage: 'Host Risk Data',
}
);
export const INDICATOR_TOOLTIP_CONTENT = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.indicatorEnrichmentTooltipContent',
{
@ -36,6 +43,13 @@ export const INDICATOR_TOOLTIP_CONTENT = i18n.translate(
}
);
export const INFORMATION_ARIA_LABEL = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.informationAriaLabel',
{
defaultMessage: 'Information',
}
);
export const INVESTIGATION_TOOLTIP_CONTENT = i18n.translate(
'xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentTooltipContent',
{
@ -82,6 +96,13 @@ export const NO_ENRICHMENTS_FOUND_DESCRIPTION = i18n.translate(
}
);
export const NO_HOST_RISK_DATA_DESCRIPTION = i18n.translate(
'xpack.securitySolution.alertDetails.noRiskDataDescription',
{
defaultMessage: 'These is no host risk data found for this alert',
}
);
export const CHECK_DOCS = i18n.translate('xpack.securitySolution.alertDetails.checkDocs', {
defaultMessage: 'please check out our documentation',
});
@ -117,3 +138,10 @@ export const ENRICHMENT_LOOKBACK_END_DATE = i18n.translate(
export const REFRESH = i18n.translate('xpack.securitySolution.alertDetails.refresh', {
defaultMessage: 'Refresh',
});
export const ENRICHED_DATA = i18n.translate(
'xpack.securitySolution.alertDetails.overview.enrichedDataTitle',
{
defaultMessage: 'Enriched data',
}
);

View file

@ -24,6 +24,16 @@ import { useInvestigationTimeEnrichment } from '../../containers/cti/event_enric
jest.mock('../../../common/lib/kibana');
jest.mock('../../containers/cti/event_enrichment');
jest.mock('../../../detections/containers/detection_engine/rules/use_rule_with_fallback', () => {
return {
useRuleWithFallback: jest.fn().mockReturnValue({
rule: {
note: 'investigation guide',
},
}),
};
});
jest.mock('../link_to');
describe('EventDetails', () => {
const mount = useMountAppended();
@ -37,6 +47,7 @@ describe('EventDetails', () => {
timelineTabType: TimelineTabs.query,
timelineId: 'test',
eventView: EventsViewType.summaryView,
hostRisk: { fields: [], loading: true },
};
const alertsProps = {
@ -115,6 +126,12 @@ describe('EventDetails', () => {
});
});
describe('summary view tab', () => {
it('render investigation guide', () => {
expect(alertsWrapper.find('[data-test-subj="summary-view-guide"]').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');

View file

@ -10,10 +10,10 @@ import {
EuiTabbedContentTab,
EuiSpacer,
EuiLoadingContent,
EuiLoadingSpinner,
EuiNotificationBadge,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
@ -38,6 +38,9 @@ import {
import { EnrichmentRangePicker } from './cti_details/enrichment_range_picker';
import { Reason } from './reason';
import { InvestigationGuideView } from './investigation_guide_view';
import { HostRisk } from '../../../overview/containers/overview_risky_host_links/use_hosts_risk_score';
type EventViewTab = EuiTabbedContentTab;
export type EventViewId =
@ -60,8 +63,14 @@ interface Props {
isDraggable?: boolean;
timelineTabType: TimelineTabs | 'flyout';
timelineId: string;
hostRisk: HostRisk | null;
}
export const Indent = styled.div`
padding: 0 8px;
word-break: break-word;
`;
const StyledEuiTabbedContent = styled(EuiTabbedContent)`
display: flex;
flex: 1;
@ -99,6 +108,7 @@ const EventDetailsComponent: React.FC<Props> = ({
isDraggable,
timelineId,
timelineTabType,
hostRisk,
}) => {
const [selectedTabId, setSelectedTabId] = useState<EventViewId>(EventsViewType.summaryView);
const handleTabClick = useCallback(
@ -151,8 +161,11 @@ const EventDetailsComponent: React.FC<Props> = ({
title: i18n.DUCOMENT_SUMMARY,
}}
/>
{enrichmentCount > 0 && (
{(enrichmentCount > 0 || hostRisk) && (
<ThreatSummaryView
isDraggable={isDraggable}
hostRisk={hostRisk}
browserFields={browserFields}
data={data}
eventId={id}
@ -160,11 +173,14 @@ const EventDetailsComponent: React.FC<Props> = ({
enrichments={allEnrichments}
/>
)}
{isEnrichmentsLoading && (
<>
<EuiLoadingContent lines={2} />
</>
)}
<InvestigationGuideView data={data} />
</>
),
}
@ -179,6 +195,7 @@ const EventDetailsComponent: React.FC<Props> = ({
enrichmentCount,
allEnrichments,
isEnrichmentsLoading,
hostRisk,
]
);

View file

@ -0,0 +1,243 @@
/*
* 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 { get, getOr, find, isEmpty } from 'lodash/fp';
import * as i18n from './translations';
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,
ALERTS_HEADERS_RULE_NAME,
SIGNAL_STATUS,
ALERTS_HEADERS_TARGET_IMPORT_HASH,
TIMESTAMP,
ALERTS_HEADERS_RULE_DESCRIPTION,
} from '../../../detections/components/alerts_table/translations';
import {
AGENT_STATUS_FIELD_NAME,
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 { SummaryRow } from './helpers';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check';
import { EventCode } from '../../../../common/ecs/event';
interface EventSummaryField {
id: string;
label?: string;
linkField?: string;
fieldType?: string;
overrideField?: string;
}
const defaultDisplayFields: EventSummaryField[] = [
{ id: 'signal.status', label: SIGNAL_STATUS },
{ id: '@timestamp', label: 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: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS },
{ 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 processCategoryFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'process.name' },
{ id: 'process.parent.name' },
{ id: 'process.args' },
];
const networkCategoryFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'destination.address' },
{ id: 'destination.port' },
{ id: 'process.name' },
];
const memoryShellCodeAlertFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'rule.name', label: ALERTS_HEADERS_RULE_NAME },
{
id: 'Target.process.thread.Ext.start_address_details.memory_pe.imphash',
label: ALERTS_HEADERS_TARGET_IMPORT_HASH,
},
];
const behaviorAlertFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'rule.description', label: ALERTS_HEADERS_RULE_DESCRIPTION },
];
const memorySignatureAlertFields: EventSummaryField[] = [
...defaultDisplayFields,
{ id: 'rule.name', label: ALERTS_HEADERS_RULE_NAME },
];
function getEventFieldsToDisplay({
eventCategory,
eventCode,
}: {
eventCategory: string;
eventCode?: string;
}): EventSummaryField[] {
switch (eventCode) {
// memory protection fields
case EventCode.SHELLCODE_THREAD:
return memoryShellCodeAlertFields;
case EventCode.MEMORY_SIGNATURE:
return memorySignatureAlertFields;
case EventCode.BEHAVIOR:
return behaviorAlertFields;
}
switch (eventCategory) {
case 'network':
return networkCategoryFields;
case 'process':
return processCategoryFields;
}
return defaultDisplayFields;
}
export const getSummaryRows = ({
data,
browserFields,
timelineId,
eventId,
isDraggable = false,
}: {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
timelineId: string;
eventId: string;
isDraggable?: boolean;
}) => {
const eventCategoryField = find({ category: 'event', field: 'event.category' }, data);
const eventCategory = Array.isArray(eventCategoryField?.originalValue)
? eventCategoryField?.originalValue[0]
: eventCategoryField?.originalValue;
const eventCodeField = find({ category: 'event', field: 'event.code' }, data);
const eventCode = Array.isArray(eventCodeField?.originalValue)
? eventCodeField?.originalValue?.[0]
: eventCodeField?.originalValue;
const tableFields = getEventFieldsToDisplay({ eventCategory, eventCode });
return data != null
? tableFields.reduce<SummaryRow[]>((acc, item) => {
const initialDescription = {
contextId: timelineId,
eventId,
isDraggable,
value: null,
fieldType: 'string',
linkValue: undefined,
timelineId,
};
const field = data.find((d) => d.field === item.id);
if (!field || isEmpty(field?.values)) {
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 fieldName = field.field ?? '';
const browserField = get([category, 'fields', fieldName], browserFields);
const description = {
...initialDescription,
data: {
field: field.field,
format: browserField?.format ?? '',
type: browserField?.type ?? '',
isObjectArray: field.isObjectArray,
...(item.overrideField ? { field: item.overrideField } : {}),
},
values: field.values,
linkValue: linkValue ?? undefined,
fieldFromBrowserField: browserField,
};
if (item.id === 'agent.id' && !isAlertFromEndpointEvent({ data })) {
return acc;
}
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,
values: [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,
values: [`count(${parsedValue.field}) == ${parsedValue.value}`],
},
},
];
} catch (err) {
return acc;
}
}
return [
...acc,
{
title: item.label ?? item.id,
description,
},
];
}, [])
: [];
};

View file

@ -0,0 +1,57 @@
/*
* 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 { EuiSpacer, EuiHorizontalRule, EuiTitle, EuiText } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
import { useRuleWithFallback } from '../../../detections/containers/detection_engine/rules/use_rule_with_fallback';
import { MarkdownRenderer } from '../markdown_editor';
import { LineClamp } from '../line_clamp';
import { TimelineEventsDetailsItem } from '../../../../common';
export const Indent = styled.div`
padding: 0 8px;
word-break: break-word;
`;
const InvestigationGuideViewComponent: React.FC<{
data: TimelineEventsDetailsItem[];
}> = ({ data }) => {
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 } = useRuleWithFallback(ruleId);
if (!maybeRule?.note) {
return null;
}
return (
<>
<EuiHorizontalRule />
<EuiTitle size="xxxs" data-test-subj="summary-view-guide">
<h5>{i18n.INVESTIGATION_GUIDE}</h5>
</EuiTitle>
<EuiSpacer size="s" />
<Indent>
<EuiText size="xs">
<LineClamp lineClampHeight={4.5}>
<MarkdownRenderer>{maybeRule.note}</MarkdownRenderer>
</LineClamp>
</EuiText>
</Indent>
</>
);
};
export const InvestigationGuideView = React.memo(InvestigationGuideViewComponent);

View file

@ -36,7 +36,7 @@ export const FieldValueCell = React.memo(
values,
}: FieldValueCellProps) => {
return (
<div>
<div data-test-subj={`event-field-${data.field}`}>
{values != null &&
values.map((value, i) => {
if (fieldFromBrowserField == null) {

View file

@ -11,7 +11,6 @@ import { cloneDeep } from 'lodash/fp';
import { render, screen } from '@testing-library/react';
import { I18nProvider } from '@kbn/i18n/react';
import { ThemeProvider } from 'styled-components';
import { useRiskyHostLinks } from '../../containers/overview_risky_host_links/use_risky_host_links';
import { mockTheme } from '../overview_cti_links/mock';
import { RiskyHostLinks } from '.';
import { createStore, State } from '../../../common/store';
@ -23,11 +22,12 @@ import {
} from '../../../common/mock';
import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href';
import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links';
import { useHostsRiskScore } from '../../containers/overview_risky_host_links/use_hosts_risk_score';
jest.mock('../../../common/lib/kibana');
jest.mock('../../containers/overview_risky_host_links/use_risky_host_links');
const useRiskyHostLinksMock = useRiskyHostLinks as jest.Mock;
jest.mock('../../containers/overview_risky_host_links/use_hosts_risk_score');
const useHostsRiskScoreMock = useHostsRiskScore as jest.Mock;
jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href');
const useRiskyHostsDashboardButtonHrefMock = useRiskyHostsDashboardButtonHref as jest.Mock;
@ -51,10 +51,10 @@ describe('RiskyHostLinks', () => {
});
it('renders enabled module view if module is enabled', () => {
useRiskyHostLinksMock.mockReturnValueOnce({
useHostsRiskScoreMock.mockReturnValueOnce({
loading: false,
isModuleEnabled: true,
listItems: [],
result: [],
});
render(
@ -62,10 +62,10 @@ describe('RiskyHostLinks', () => {
<I18nProvider>
<ThemeProvider theme={mockTheme}>
<RiskyHostLinks
to={'now'}
from={'now-30d'}
deleteQuery={jest.fn()}
setQuery={jest.fn()}
timerange={{
to: 'now',
from: 'now-30d',
}}
/>
</ThemeProvider>
</I18nProvider>
@ -76,10 +76,10 @@ describe('RiskyHostLinks', () => {
});
it('renders disabled module view if module is disabled', () => {
useRiskyHostLinksMock.mockReturnValueOnce({
useHostsRiskScoreMock.mockReturnValueOnce({
loading: false,
isModuleEnabled: false,
listItems: [],
result: [],
});
render(
@ -87,10 +87,10 @@ describe('RiskyHostLinks', () => {
<I18nProvider>
<ThemeProvider theme={mockTheme}>
<RiskyHostLinks
to={'now'}
from={'now-30d'}
deleteQuery={jest.fn()}
setQuery={jest.fn()}
timerange={{
to: 'now',
from: 'now-30d',
}}
/>
</ThemeProvider>
</I18nProvider>

View file

@ -7,18 +7,25 @@
import React from 'react';
import { GlobalTimeArgs } from '../../../common/containers/use_global_time';
import { useRiskyHostLinks } from '../../containers/overview_risky_host_links/use_risky_host_links';
import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module';
import { RiskyHostsDisabledModule } from './risky_hosts_disabled_module';
export type RiskyHostLinksProps = Pick<GlobalTimeArgs, 'from' | 'to' | 'deleteQuery' | 'setQuery'>;
import { useHostsRiskScore } from '../../containers/overview_risky_host_links/use_hosts_risk_score';
export interface RiskyHostLinksProps {
timerange: { to: string; from: string };
}
const RiskyHostLinksComponent: React.FC<RiskyHostLinksProps> = (props) => {
const { listItems, isModuleEnabled } = useRiskyHostLinks(props);
const RiskyHostLinksComponent: React.FC<RiskyHostLinksProps> = ({ timerange }) => {
const hostRiskScore = useHostsRiskScore({ timerange });
switch (isModuleEnabled) {
switch (hostRiskScore?.isModuleEnabled) {
case true:
return <RiskyHostsEnabledModule to={props.to} from={props.from} listItems={listItems} />;
return (
<RiskyHostsEnabledModule
to={timerange.to}
from={timerange.from}
hostRiskScore={hostRiskScore}
/>
);
case false:
return <RiskyHostsDisabledModule />;
case undefined:

View file

@ -12,7 +12,7 @@ import { DisabledLinkPanel } from '../link_panel/disabled_link_panel';
import { RiskyHostsPanelView } from './risky_hosts_panel_view';
import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module';
const RISKY_HOSTS_DOC_LINK =
export const RISKY_HOSTS_DOC_LINK =
'https://www.github.com/elastic/detection-rules/blob/main/docs/experimental-machine-learning/host-risk-score.md';
export const RiskyHostsDisabledModuleComponent = () => (

View file

@ -52,7 +52,19 @@ describe('RiskyHostsEnabledModule', () => {
<I18nProvider>
<ThemeProvider theme={mockTheme}>
<RiskyHostsEnabledModule
listItems={[{ title: 'a', count: 1, path: '' }]}
hostRiskScore={{
loading: false,
isModuleEnabled: true,
result: [
{
host: {
name: 'a',
},
risk_score: 1,
risk: '',
},
],
}}
to={'now'}
from={'now-30d'}
/>

View file

@ -5,17 +5,32 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { RiskyHostsPanelView } from './risky_hosts_panel_view';
import { LinkPanelListItem } from '../link_panel';
import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href';
import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links';
import { HostRisk } from '../../containers/overview_risky_host_links/use_hosts_risk_score';
import { HostsRiskScore } from '../../../../common';
const getListItemsFromHits = (items: HostsRiskScore[]): LinkPanelListItem[] => {
return items.map(({ host, risk_score: count, risk: copy }) => ({
title: host.name,
count,
copy,
path: '',
}));
};
const RiskyHostsEnabledModuleComponent: React.FC<{
from: string;
listItems: LinkPanelListItem[];
hostRiskScore: HostRisk;
to: string;
}> = ({ listItems, to, from }) => {
}> = ({ hostRiskScore, to, from }) => {
const listItems = useMemo(
() => getListItemsFromHits(hostRiskScore?.result || []),
[hostRiskScore]
);
const { buttonHref } = useRiskyHostsDashboardButtonHref(to, from);
const { listItemsWithLinks } = useRiskyHostsDashboardLinks(to, from, listItems);

View file

@ -14,7 +14,7 @@ import { LinkPanelViewProps } from '../link_panel/types';
import { Link } from '../link_panel/link';
import * as i18n from './translations';
import { VIEW_DASHBOARD } from '../overview_cti_links/translations';
import { QUERY_ID as RiskyHostsQueryId } from '../../containers/overview_risky_host_links/use_risky_host_links';
import { QUERY_ID as RiskyHostsQueryId } from '../../containers/overview_risky_host_links/use_hosts_risk_score';
import { NavigateToHost } from './navigate_to_host';
const columns: Array<EuiTableFieldDataColumnType<LinkPanelListItem>> = [

View file

@ -9,61 +9,61 @@ import { i18n } from '@kbn/i18n';
import { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useRiskyHostsComplete } from './use_risky_hosts';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { useKibana } from '../../../common/lib/kibana';
import { inputsActions } from '../../../common/store/actions';
import { LinkPanelListItem } from '../../components/link_panel';
import { RISKY_HOSTS_INDEX } from '../../../../common/constants';
import { HOST_RISK_SCORES_INDEX } from '../../../../common/constants';
import { isIndexNotFoundError } from '../../../common/utils/exceptions';
import { HostsRiskScore } from '../../../../common';
import { useHostsRiskScoreComplete } from './use_hosts_risk_score_complete';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
export const QUERY_ID = 'risky_hosts';
export const QUERY_ID = 'host_risk_score';
const noop = () => {};
export interface RiskyHost {
host: {
name: string;
};
risk_score: number;
risk: string;
}
const isRecord = (item: unknown): item is Record<string, unknown> =>
typeof item === 'object' && !!item;
const isRiskyHostHit = (item: unknown): item is RiskyHost =>
const isHostsRiskScoreHit = (item: unknown): item is HostsRiskScore =>
isRecord(item) &&
isRecord(item.host) &&
typeof item.host.name === 'string' &&
typeof item.risk_score === 'number' &&
typeof item.risk === 'string';
const getListItemsFromHits = (items: RiskyHost[]): LinkPanelListItem[] => {
return items.map(({ host, risk_score: count, risk: copy }) => ({
title: host.name,
count,
copy,
path: '',
}));
};
export interface HostRisk {
loading: boolean;
isModuleEnabled?: boolean;
result?: HostsRiskScore[];
}
export const useRiskyHostLinks = ({ to, from }: { to: string; from: string }) => {
export const useHostsRiskScore = ({
timerange,
hostName,
}: {
timerange?: { to: string; from: string };
hostName?: string;
}): HostRisk | null => {
const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled');
const [isModuleEnabled, setIsModuleEnabled] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(riskyHostsFeatureEnabled);
const { addError } = useAppToasts();
const { data } = useKibana().services;
const dispatch = useDispatch();
const { error, loading, result, start } = useRiskyHostsComplete();
const { error, result, start, loading: isHostsRiskScoreLoading } = useHostsRiskScoreComplete();
const deleteQuery = useCallback(() => {
dispatch(inputsActions.deleteOneQuery({ inputId: 'global', id: QUERY_ID }));
}, [dispatch]);
useEffect(() => {
if (!loading && result) {
if (!isHostsRiskScoreLoading && result) {
setIsModuleEnabled(true);
setLoading(false);
dispatch(
inputsActions.setQuery({
inputId: 'global',
@ -72,43 +72,51 @@ export const useRiskyHostLinks = ({ to, from }: { to: string; from: string }) =>
dsl: result.inspect?.dsl ?? [],
response: [JSON.stringify(result.rawResponse, null, 2)],
},
loading,
loading: isHostsRiskScoreLoading,
refetch: noop,
})
);
}
return deleteQuery;
}, [deleteQuery, dispatch, loading, result, setIsModuleEnabled]);
}, [deleteQuery, dispatch, isHostsRiskScoreLoading, result, setIsModuleEnabled]);
useEffect(() => {
if (error) {
if (isIndexNotFoundError(error)) {
setIsModuleEnabled(false);
setLoading(false);
} else {
addError(error, {
title: i18n.translate('xpack.securitySolution.overview.riskyHostsError', {
defaultMessage: 'Error Fetching Risky Hosts',
title: i18n.translate('xpack.securitySolution.overview.hostsRiskError', {
defaultMessage: 'Error Fetching Hosts Risk',
}),
});
setLoading(false);
setIsModuleEnabled(true);
}
}
}, [addError, error, setIsModuleEnabled]);
useEffect(() => {
start({
data,
timerange: { to, from, interval: '' },
defaultIndex: [RISKY_HOSTS_INDEX],
filterQuery: '',
});
}, [start, data, to, from]);
if (riskyHostsFeatureEnabled && (hostName || timerange)) {
start({
data,
timerange: timerange ? { to: timerange.to, from: timerange.from, interval: '' } : undefined,
hostName,
defaultIndex: [HOST_RISK_SCORES_INDEX],
});
}
}, [start, data, timerange, hostName, riskyHostsFeatureEnabled]);
if ((!hostName && !timerange) || !riskyHostsFeatureEnabled) {
return null;
}
const hits = result?.rawResponse?.hits?.hits;
return {
listItems: isRiskyHostHit(result?.rawResponse?.hits?.hits?.[0]?._source)
? getListItemsFromHits(
result?.rawResponse?.hits?.hits?.map((hit) => hit._source) as RiskyHost[]
)
result: isHostsRiskScoreHit(hits?.[0]?._source)
? (hits?.map((hit) => hit._source) as HostsRiskScore[])
: [],
isModuleEnabled,
loading,

View file

@ -15,28 +15,28 @@ import {
} from '../../../../../../../src/plugins/data/public';
import {
HostsQueries,
HostsRiskyHostsRequestOptions,
HostsRiskyHostsStrategyResponse,
HostsRiskScoreRequestOptions,
HostsRiskScoreStrategyResponse,
} from '../../../../common';
type GetRiskyHostsProps = HostsRiskyHostsRequestOptions & {
type GetHostsRiskScoreProps = HostsRiskScoreRequestOptions & {
data: DataPublicPluginStart;
signal: AbortSignal;
};
export const getRiskyHosts = ({
export const getHostsRiskScore = ({
data,
defaultIndex,
filterQuery,
timerange,
hostName,
signal,
}: GetRiskyHostsProps): Observable<HostsRiskyHostsStrategyResponse> =>
data.search.search<HostsRiskyHostsRequestOptions, HostsRiskyHostsStrategyResponse>(
}: GetHostsRiskScoreProps): Observable<HostsRiskScoreStrategyResponse> =>
data.search.search<HostsRiskScoreRequestOptions, HostsRiskScoreStrategyResponse>(
{
defaultIndex,
factoryQueryType: HostsQueries.riskyHosts,
filterQuery,
factoryQueryType: HostsQueries.hostsRiskScore,
timerange,
hostName,
},
{
strategy: 'securitySolutionSearchStrategy',
@ -44,16 +44,16 @@ export const getRiskyHosts = ({
}
);
export const getRiskyHostsComplete = (
props: GetRiskyHostsProps
): Observable<HostsRiskyHostsStrategyResponse> => {
return getRiskyHosts(props).pipe(
export const getHostsRiskScoreComplete = (
props: GetHostsRiskScoreProps
): Observable<HostsRiskScoreStrategyResponse> => {
return getHostsRiskScore(props).pipe(
filter((response) => {
return isErrorResponse(response) || isCompleteResponse(response);
})
);
};
const getRiskyHostsWithOptionalSignal = withOptionalSignal(getRiskyHostsComplete);
const getHostsRiskScoreWithOptionalSignal = withOptionalSignal(getHostsRiskScoreComplete);
export const useRiskyHostsComplete = () => useObservable(getRiskyHostsWithOptionalSignal);
export const useHostsRiskScoreComplete = () => useObservable(getHostsRiskScoreWithOptionalSignal);

View file

@ -31,8 +31,8 @@ import {
} from '../components/overview_cti_links/mock';
import { useCtiDashboardLinks } from '../containers/overview_cti_links';
import { EndpointPrivileges } from '../../common/components/user_privileges/use_endpoint_privileges';
import { useRiskyHostLinks } from '../containers/overview_risky_host_links/use_risky_host_links';
import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
import { useHostsRiskScore } from '../containers/overview_risky_host_links/use_hosts_risk_score';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/containers/source');
@ -86,9 +86,9 @@ jest.mock('../containers/overview_cti_links/use_is_threat_intel_module_enabled')
const useIsThreatIntelModuleEnabledMock = useIsThreatIntelModuleEnabled as jest.Mock;
useIsThreatIntelModuleEnabledMock.mockReturnValue(true);
jest.mock('../containers/overview_risky_host_links/use_risky_host_links');
const useRiskyHostLinksMock = useRiskyHostLinks as jest.Mock;
useRiskyHostLinksMock.mockReturnValue({
jest.mock('../containers/overview_risky_host_links/use_hosts_risk_score');
const useHostsRiskScoreMock = useHostsRiskScore as jest.Mock;
useHostsRiskScoreMock.mockReturnValue({
loading: false,
isModuleEnabled: false,
listItems: [],

View file

@ -164,10 +164,10 @@ const OverviewComponent = () => {
<EuiFlexItem grow={1}>
{riskyHostsEnabled && (
<RiskyHostLinks
deleteQuery={deleteQuery}
from={from}
setQuery={setQuery}
to={to}
timerange={{
from,
to,
}}
/>
)}
</EuiFlexItem>

View file

@ -435,6 +435,7 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should
"indexName": "my-index",
}
}
hostRisk={null}
isAlert={false}
isDraggable={false}
loading={true}
@ -954,6 +955,7 @@ Array [
"indexName": "my-index",
}
}
hostRisk={null}
isAlert={false}
isDraggable={false}
loading={true}
@ -1990,6 +1992,7 @@ Array [
"indexName": "my-index",
}
}
hostRisk={null}
isAlert={false}
isDraggable={false}
loading={true}

View file

@ -22,6 +22,7 @@ import { BrowserFields } from '../../../../common/containers/source';
import { EventDetails } from '../../../../common/components/event_details/event_details';
import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import * as i18n from './translations';
import { HostRisk } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score';
export type HandleOnEventClosed = () => void;
interface Props {
@ -34,6 +35,7 @@ interface Props {
messageHeight?: number;
timelineTabType: TimelineTabs | 'flyout';
timelineId: string;
hostRisk: HostRisk | null;
}
interface ExpandableEventTitleProps {
@ -90,6 +92,7 @@ export const ExpandableEvent = React.memo<Props>(
isDraggable,
loading,
detailsData,
hostRisk,
}) => {
if (!event.eventId) {
return <EuiTextColor color="subdued">{i18n.EVENT_DETAILS_PLACEHOLDER}</EuiTextColor>;
@ -110,6 +113,7 @@ export const ExpandableEvent = React.memo<Props>(
isDraggable={isDraggable}
timelineId={timelineId}
timelineTabType={timelineTabType}
hostRisk={hostRisk}
/>
</StyledEuiFlexItem>
</StyledFlexGroup>

View file

@ -34,6 +34,7 @@ import { TimelineNonEcsData } from '../../../../../common';
import { Ecs } from '../../../../../common/ecs';
import { EventDetailsFooter } from './footer';
import { EntityType } from '../../../../../../timelines/common';
import { useHostsRiskScore } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score';
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflow {
@ -124,6 +125,10 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
[detailsData]
);
const hostRisk = useHostsRiskScore({
hostName,
});
const backToAlertDetailsLink = useMemo(() => {
return (
<>
@ -192,6 +197,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
loading={loading}
timelineId={timelineId}
timelineTabType="flyout"
hostRisk={hostRisk}
/>
)}
</StyledEuiFlyoutBody>
@ -224,6 +230,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
loading={loading}
timelineId={timelineId}
timelineTabType={tabType}
hostRisk={hostRisk}
/>
</>
);

View file

@ -10,7 +10,8 @@ import { HostsQueries, HostsKpiQueries } from '../../../../../common/search_stra
import { allHosts } from './all';
import { hostDetails } from './details';
import { hostOverview } from './overview';
import { riskyHosts } from './risky_hosts';
import { riskScore } from './risk_score';
import { firstOrLastSeenHost } from './last_first_seen';
import { uncommonProcesses } from './uncommon_processes';
import { authentications, authenticationsEntities } from './authentications';
@ -27,7 +28,7 @@ jest.mock('./authentications');
jest.mock('./kpi/authentications');
jest.mock('./kpi/hosts');
jest.mock('./kpi/unique_ips');
jest.mock('./risky_hosts');
jest.mock('./risk_score');
describe('hostsFactory', () => {
test('should include correct apis', () => {
@ -39,7 +40,7 @@ describe('hostsFactory', () => {
[HostsQueries.uncommonProcesses]: uncommonProcesses,
[HostsQueries.authentications]: authentications,
[HostsQueries.authenticationsEntities]: authenticationsEntities,
[HostsQueries.riskyHosts]: riskyHosts,
[HostsQueries.hostsRiskScore]: riskScore,
[HostsKpiQueries.kpiAuthentications]: hostsKpiAuthentications,
[HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities,
[HostsKpiQueries.kpiHosts]: hostsKpiHosts,

View file

@ -21,7 +21,7 @@ import { authentications, authenticationsEntities } from './authentications';
import { hostsKpiAuthentications, hostsKpiAuthenticationsEntities } from './kpi/authentications';
import { hostsKpiHosts, hostsKpiHostsEntities } from './kpi/hosts';
import { hostsKpiUniqueIps, hostsKpiUniqueIpsEntities } from './kpi/unique_ips';
import { riskyHosts } from './risky_hosts';
import { riskScore } from './risk_score';
export const hostsFactory: Record<
HostsQueries | HostsKpiQueries,
@ -35,7 +35,7 @@ export const hostsFactory: Record<
[HostsQueries.uncommonProcesses]: uncommonProcesses,
[HostsQueries.authentications]: authentications,
[HostsQueries.authenticationsEntities]: authenticationsEntities,
[HostsQueries.riskyHosts]: riskyHosts,
[HostsQueries.hostsRiskScore]: riskScore,
[HostsKpiQueries.kpiAuthentications]: hostsKpiAuthentications,
[HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities,
[HostsKpiQueries.kpiHosts]: hostsKpiHosts,

View file

@ -6,23 +6,23 @@
*/
import { SecuritySolutionFactory } from '../../types';
import { HostsQueries } from '../../../../../../common';
import {
HostsRiskScoreRequestOptions,
HostsQueries,
HostsRiskScoreStrategyResponse,
} from '../../../../../../common';
import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import { buildRiskyHostsQuery } from './query.risky_hosts.dsl';
import {
HostsRiskyHostsRequestOptions,
HostsRiskyHostsStrategyResponse,
} from '../../../../../../common/search_strategy/security_solution/hosts/risky_hosts';
import { buildHostsRiskScoreQuery } from './query.hosts_risk.dsl';
export const riskyHosts: SecuritySolutionFactory<HostsQueries.riskyHosts> = {
buildDsl: (options: HostsRiskyHostsRequestOptions) => buildRiskyHostsQuery(options),
export const riskScore: SecuritySolutionFactory<HostsQueries.hostsRiskScore> = {
buildDsl: (options: HostsRiskScoreRequestOptions) => buildHostsRiskScoreQuery(options),
parse: async (
options: HostsRiskyHostsRequestOptions,
options: HostsRiskScoreRequestOptions,
response: IEsSearchResponse<unknown>
): Promise<HostsRiskyHostsStrategyResponse> => {
): Promise<HostsRiskScoreStrategyResponse> => {
const inspect = {
dsl: [inspectStringifyObject(buildRiskyHostsQuery(options))],
dsl: [inspectStringifyObject(buildHostsRiskScoreQuery(options))],
};
return {

View file

@ -5,26 +5,30 @@
* 2.0.
*/
import { HostsRiskyHostsRequestOptions } from '../../../../../../common/search_strategy/security_solution/hosts/risky_hosts';
import { createQueryFilterClauses } from '../../../../../utils/build_query';
import { HostsRiskScoreRequestOptions } from '../../../../../../common';
export const buildRiskyHostsQuery = ({
filterQuery,
timerange: { from, to },
export const buildHostsRiskScoreQuery = ({
timerange,
hostName,
defaultIndex,
}: HostsRiskyHostsRequestOptions) => {
const filter = [
...createQueryFilterClauses(filterQuery),
{
}: HostsRiskScoreRequestOptions) => {
const filter = [];
if (timerange) {
filter.push({
range: {
'@timestamp': {
gte: from,
lte: to,
gte: timerange.from,
lte: timerange.to,
format: 'strict_date_optional_time',
},
},
},
];
});
}
if (hostName) {
filter.push({ term: { 'host.name': hostName } });
}
const dslQuery = {
index: defaultIndex,

View file

@ -22351,9 +22351,7 @@
"xpack.securitySolution.eventDetails.copyToClipboard": "クリップボードにコピー",
"xpack.securitySolution.eventDetails.copyToClipboardTooltip": "クリップボードにコピー",
"xpack.securitySolution.eventDetails.ctiSummary.indicatorEnrichmentTooltipContent": "このフィールド値は、作成したルールの脅威インテリジェンス指標と一致しました。",
"xpack.securitySolution.eventDetails.ctiSummary.indicatorEnrichmentTooltipTitle": "脅威一致が検出されました",
"xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentTooltipContent": "このフィールド値には脅威インテリジェンスソースの別の情報があります。",
"xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentTooltipTitle": "Threat Intelligenceで拡張",
"xpack.securitySolution.eventDetails.ctiSummary.providerPreposition": "開始",
"xpack.securitySolution.eventDetails.description": "説明",
"xpack.securitySolution.eventDetails.field": "フィールド",

View file

@ -22700,9 +22700,7 @@
"xpack.securitySolution.eventDetails.copyToClipboard": "复制到剪贴板",
"xpack.securitySolution.eventDetails.copyToClipboardTooltip": "复制到剪贴板",
"xpack.securitySolution.eventDetails.ctiSummary.indicatorEnrichmentTooltipContent": "此字段值使用您创建的规则匹配威胁情报指标。",
"xpack.securitySolution.eventDetails.ctiSummary.indicatorEnrichmentTooltipTitle": "检测到威胁匹配",
"xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentTooltipContent": "此字段值具有威胁情报源提供的其他信息。",
"xpack.securitySolution.eventDetails.ctiSummary.investigationEnrichmentTooltipTitle": "已使用威胁情报扩充",
"xpack.securitySolution.eventDetails.ctiSummary.providerPreposition": "来自",
"xpack.securitySolution.eventDetails.description": "描述",
"xpack.securitySolution.eventDetails.field": "字段",