[SIEM] Host Details Page Updates (#37769) (#38028)

* toggle to exclude host ID links on host details pg

* first attempt at hiding host column

* switch to conditionally splicing array of columns

* exclude dest from auth table on host details pg

* minor overview style touchups

* rm some extranous EuiFlexGroup; more need removal

* snapshots update

* Revert "snapshots update"

This reverts commit bdd6b8e316.

* update siem snapshots

* remove unused arg

* change uncommon process column order per #503

* simplify if condition, per steph

* update tests, per steph

* simplify `hostIdRenderer` per xavier

* update snapshots

* fix/improve unit tests, per xavier

* Revert "update snapshots"

This reverts commit dc0f79606b.

* add host links in uncommon processes table

* update uncommon process test, thx to xavier

# Conflicts:
#	x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx
This commit is contained in:
Michael Marcialis 2019-06-04 20:02:35 -04:00 committed by GitHub
parent 1c342e3ad6
commit bc5d6e59b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 277 additions and 118 deletions

View file

@ -36,51 +36,33 @@ exports[`Field Renderers #dateRenderer it renders correctly against snapshot 1`]
exports[`Field Renderers #hostIdRenderer it renders correctly against snapshot 1`] = `
<Component>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
<pure(Component)
field="host.name"
id="ip-overview-host-name"
value="raspberrypi"
>
<EuiFlexItem
grow={false}
<pure(Component)
hostName="raspberrypi"
>
<pure(Component)
field="host.name"
id="ip-overview-host-name"
value="raspberrypi"
>
<pure(Component)
hostName="raspberrypi"
>
raspberrypi
</pure(Component)>
</pure(Component)>
</EuiFlexItem>
</EuiFlexGroup>
raspberrypi
</pure(Component)>
</pure(Component)>
</Component>
`;
exports[`Field Renderers #hostNameRenderer it renders correctly against snapshot 1`] = `
<Component>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
<pure(Component)
field="host.name"
id="ip-overview-host-name"
value="raspberrypi"
>
<EuiFlexItem
grow={false}
<pure(Component)
hostName="raspberrypi"
>
<pure(Component)
field="host.name"
id="ip-overview-host-name"
value="raspberrypi"
>
<pure(Component)
hostName="raspberrypi"
>
raspberrypi
</pure(Component)>
</pure(Component)>
</EuiFlexItem>
</EuiFlexGroup>
raspberrypi
</pure(Component)>
</pure(Component)>
</Component>
`;
@ -230,16 +212,14 @@ exports[`Field Renderers #reputationRenderer it renders correctly against snapsh
<pure(Component)
link="10.10.10.10"
>
View at virustotal.com
virustotal.com
</pure(Component)>
<pure(Component) />
<br />
,
<pure(Component)
domain="10.10.10.10"
>
View at talosIntelligence.com
talosIntelligence.com
</pure(Component)>
<pure(Component) />
</Component>
`;
@ -351,8 +331,7 @@ exports[`Field Renderers #whoisRenderer it renders correctly against snapshot 1`
<pure(Component)
domain="10.10.10.10"
>
View at iana.org
iana.org
</pure(Component)>
<pure(Component) />
</Component>
`;

View file

@ -6,7 +6,7 @@
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiText } from '@elastic/eui';
import { getOr } from 'lodash/fp';
import React, { useState } from 'react';
import React, { Fragment, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { pure } from 'recompose';
@ -19,7 +19,6 @@ import {
} from '../../graphql/types';
import { DefaultDraggable } from '../draggables';
import { getEmptyTagValue } from '../empty_value';
import { ExternalLinkIcon } from '../external_link_icon';
import { FormattedDate } from '../formatted_date';
import { HostDetailsLink, ReputationLink, VirusTotalLink, WhoIsLink } from '../links';
@ -35,7 +34,7 @@ export const locationRenderer = (fieldNames: string[], data: IpOverviewData): Re
{fieldNames.map((fieldName, index) => {
const locationValue = getOr('', fieldName, data);
return (
<React.Fragment key={`${IpOverviewId}-${fieldName}`}>
<Fragment key={`${IpOverviewId}-${fieldName}`}>
{index ? ',\u00A0' : ''}
<EuiFlexItem grow={false}>
<DefaultDraggable
@ -44,7 +43,7 @@ export const locationRenderer = (fieldNames: string[], data: IpOverviewData): Re
value={locationValue}
/>
</EuiFlexItem>
</React.Fragment>
</Fragment>
);
})}
</EuiFlexGroup>
@ -80,52 +79,53 @@ export const autonomousSystemRenderer = (
getEmptyTagValue()
);
export const hostIdRenderer = (host: HostEcsFields, ipFilter?: string): React.ReactElement =>
host.id && host.ip && (!(ipFilter != null) || host.ip.includes(ipFilter)) ? (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
{host.name && host.name[0] != null ? (
<DefaultDraggable id={`${IpOverviewId}-host-id`} field={'host.id'} value={host.id[0]}>
interface HostIdRendererTypes {
host: HostEcsFields;
ipFilter?: string;
noLink?: boolean;
}
export const hostIdRenderer = ({
host,
ipFilter,
noLink,
}: HostIdRendererTypes): React.ReactElement =>
host.id && host.ip && (ipFilter == null || host.ip.includes(ipFilter)) ? (
<>
{host.name && host.name[0] != null ? (
<DefaultDraggable id={`${IpOverviewId}-host-id`} field="host.id" value={host.id[0]}>
{noLink ? (
<>{host.id}</>
) : (
<HostDetailsLink hostName={host.name[0]}>{host.id}</HostDetailsLink>
</DefaultDraggable>
) : (
<>{host.id}</>
)}
</EuiFlexItem>
</EuiFlexGroup>
)}
</DefaultDraggable>
) : (
<>{host.id}</>
)}
</>
) : (
getEmptyTagValue()
);
export const hostNameRenderer = (host: HostEcsFields, ipFilter?: string): React.ReactElement =>
host.name && host.name[0] && host.ip && (!(ipFilter != null) || host.ip.includes(ipFilter)) ? (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<DefaultDraggable id={`${IpOverviewId}-host-name`} field={'host.name'} value={host.name[0]}>
<HostDetailsLink hostName={host.name[0]}>
{host.name ? host.name : getEmptyTagValue()}
</HostDetailsLink>
</DefaultDraggable>
</EuiFlexItem>
</EuiFlexGroup>
<DefaultDraggable id={`${IpOverviewId}-host-name`} field={'host.name'} value={host.name[0]}>
<HostDetailsLink hostName={host.name[0]}>
{host.name ? host.name : getEmptyTagValue()}
</HostDetailsLink>
</DefaultDraggable>
) : (
getEmptyTagValue()
);
export const whoisRenderer = (ip: string) => (
<>
<WhoIsLink domain={ip}>{i18n.VIEW_WHOIS}</WhoIsLink>
<ExternalLinkIcon />
</>
);
export const whoisRenderer = (ip: string) => <WhoIsLink domain={ip}>{i18n.VIEW_WHOIS}</WhoIsLink>;
export const reputationRenderer = (ip: string): React.ReactElement => (
<>
<VirusTotalLink link={ip}>{i18n.VIEW_VIRUS_TOTAL}</VirusTotalLink>
<ExternalLinkIcon />
<br />
{', '}
<ReputationLink domain={ip}>{i18n.VIEW_TALOS_INTELLIGENCE}</ReputationLink>
<ExternalLinkIcon />
</>
);

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Authentication Table Component rendering it renders the default Authentication table 1`] = `
exports[`Authentication Table Component rendering it renders the authentication table 1`] = `
<Connect(pure(Component))
data={
Array [

View file

@ -11,10 +11,13 @@ import * as React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { apolloClientObservable, mockGlobalState } from '../../../../mock';
import { AuthenticationsEdges } from '../../../../graphql/types';
import { createStore, hostsModel, State } from '../../../../store';
import { Columns } from '../../../load_more_table';
import { AuthenticationTable } from '.';
import { mockData } from './mock';
import * as i18n from './translations';
import { AuthenticationTable, getAuthenticationColumnsCurated } from '.';
describe('Authentication Table Component', () => {
const loadMore = jest.fn();
@ -27,7 +30,7 @@ describe('Authentication Table Component', () => {
});
describe('rendering', () => {
test('it renders the default Authentication table', () => {
test('it renders the authentication table', () => {
const wrapper = shallow(
<ReduxStoreProvider store={store}>
<AuthenticationTable
@ -45,4 +48,33 @@ describe('Authentication Table Component', () => {
expect(toJson(wrapper)).toMatchSnapshot();
});
});
describe('columns', () => {
test('on hosts page, we expect to get all columns', () => {
expect(getAuthenticationColumnsCurated(hostsModel.HostsType.page).length).toEqual(9);
});
test('on host details page, we expect to remove two columns', () => {
const columns = getAuthenticationColumnsCurated(hostsModel.HostsType.details);
expect(columns.length).toEqual(7);
});
test('on host details page, we should not have Last Failed Destination column', () => {
const columns = getAuthenticationColumnsCurated(hostsModel.HostsType.details);
expect(
columns.includes(
(col: Columns<AuthenticationsEdges>) => col.name === i18n.LAST_FAILED_DESTINATION
)
).toEqual(false);
});
test('on host details page, we should not have Last Successful Destination column', () => {
const columns = getAuthenticationColumnsCurated(hostsModel.HostsType.details);
expect(
columns.includes(
(col: Columns<AuthenticationsEdges>) => col.name === i18n.LAST_SUCCESSFUL_DESTINATION
)
).toEqual(false);
});
});
});

View file

@ -80,7 +80,7 @@ const AuthenticationTableComponent = pure<AuthenticationTableProps>(
type,
}) => (
<LoadMoreTable
columns={getAuthenticationColumns()}
columns={getAuthenticationColumnsCurated(type)}
hasNextPage={hasNextPage}
headerCount={totalCount}
headerTitle={i18n.AUTHENTICATIONS}
@ -303,3 +303,17 @@ const getAuthenticationColumns = (): [
}),
},
];
export const getAuthenticationColumnsCurated = (pageType: hostsModel.HostsType) => {
const columns = getAuthenticationColumns();
// Columns to exclude from host details pages
if (pageType === 'details') {
return [i18n.LAST_FAILED_DESTINATION, i18n.LAST_SUCCESSFUL_DESTINATION].reduce((acc, name) => {
acc.splice(acc.findIndex(column => column.name === name), 1);
return acc;
}, columns);
}
return columns;
};

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Load More Events Table Component rendering it renders the default Events table 1`] = `
exports[`Load More Events Table Component rendering it renders the events table 1`] = `
<Connect(pure(Component))
data={
Array [

View file

@ -11,10 +11,13 @@ import * as React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { apolloClientObservable, mockGlobalState } from '../../../../mock';
import { EcsEdges } from '../../../../graphql/types';
import { createStore, hostsModel, State } from '../../../../store';
import { Columns } from '../../../load_more_table';
import { EventsTable } from './index';
import { EventsTable, getEventsColumnsCurated } from '.';
import { mockData } from './mock';
import * as i18n from './translations';
describe('Load More Events Table Component', () => {
const loadMore = jest.fn();
@ -27,7 +30,7 @@ describe('Load More Events Table Component', () => {
});
describe('rendering', () => {
test('it renders the default Events table', () => {
test('it renders the events table', () => {
const wrapper = shallow(
<ReduxStoreProvider store={store}>
<EventsTable
@ -46,4 +49,22 @@ describe('Load More Events Table Component', () => {
expect(toJson(wrapper)).toMatchSnapshot();
});
});
describe('columns', () => {
test('on hosts page, we expect to get all columns', () => {
expect(getEventsColumnsCurated(hostsModel.HostsType.page).length).toEqual(8);
});
test('on host details page, we expect to remove one column', () => {
const columns = getEventsColumnsCurated(hostsModel.HostsType.details);
expect(columns.length).toEqual(7);
});
test('on host details page, we should not have Host Name column', () => {
const columns = getEventsColumnsCurated(hostsModel.HostsType.details);
expect(columns.includes((col: Columns<EcsEdges>) => col.name === i18n.HOST_NAME)).toEqual(
false
);
});
});
});

View file

@ -79,7 +79,7 @@ const EventsTableComponent = pure<EventsTableProps>(
type,
}) => (
<LoadMoreTable
columns={getEventsColumns(type)}
columns={getEventsColumnsCurated(type)}
hasNextPage={hasNextPage}
headerCount={totalCount}
headerTitle={i18n.EVENTS}
@ -254,3 +254,17 @@ const getEventsColumns = (
},
},
];
export const getEventsColumnsCurated = (pageType: hostsModel.HostsType) => {
const columns = getEventsColumns(pageType);
// Columns to exclude from host details pages
if (pageType === 'details') {
return [i18n.HOST_NAME].reduce((acc, name) => {
acc.splice(acc.findIndex(column => column.name === name), 1);
return acc;
}, columns);
}
return columns;
};

View file

@ -8,6 +8,8 @@ import { EuiDescriptionList, EuiFlexItem } from '@elastic/eui';
import { getOr } from 'lodash/fp';
import React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { HostItem } from '../../../../graphql/types';
import { getEmptyTagValue } from '../../../empty_value';
@ -30,9 +32,17 @@ interface OwnProps {
type HostSummaryProps = OwnProps;
const DescriptionList = styled(EuiDescriptionList)`
${({ theme }) => `
dt {
font-size: ${theme.eui.euiFontSizeXS} !important;
}
`}
`;
const getDescriptionList = (descriptionList: DescriptionList[], key: number) => (
<EuiFlexItem key={key}>
<EuiDescriptionList listItems={descriptionList} />
<DescriptionList listItems={descriptionList} />
</EuiFlexItem>
);
@ -49,7 +59,9 @@ export const HostOverview = pure<HostSummaryProps>(({ data, loading }) => {
[
{
title: i18n.HOST_ID,
description: data.host ? hostIdRenderer(data.host) : getEmptyTagValue(),
description: data.host
? hostIdRenderer({ host: data.host, noLink: true })
: getEmptyTagValue(),
},
{
title: i18n.FIRST_SEEN,

View file

@ -57,12 +57,12 @@ describe('UncommonProcess Table Component', () => {
.find('.euiTableRow')
.at(0)
.find('.euiTableRowCell')
.at(5)
.at(3)
.text()
).toBe(`Hosts${getEmptyValue()}`);
});
test('it has a single host without any extra comma when the number of hosts exactly 1', () => {
test('it has a single host without any extra comma when the number of hosts is exactly 1', () => {
const wrapper = mount(
<TestProviders>
<UncommonProcessTable
@ -82,11 +82,36 @@ describe('UncommonProcess Table Component', () => {
.find('.euiTableRow')
.at(1)
.find('.euiTableRowCell')
.at(5)
.at(3)
.text()
).toBe('Hostshello-world ');
});
test('it has a single link when the number of hosts is exactly 1', () => {
const wrapper = mount(
<TestProviders>
<UncommonProcessTable
loading={false}
data={mockData.UncommonProcess.edges}
totalCount={mockData.UncommonProcess.totalCount}
hasNextPage={getOr(false, 'hasNextPage', mockData.UncommonProcess.pageInfo)!}
nextCursor={getOr(null, 'endCursor.value', mockData.UncommonProcess.pageInfo)}
loadMore={loadMore}
type={hostsModel.HostsType.page}
/>
</TestProviders>
);
expect(
wrapper
.find('.euiTableRow')
.at(1)
.find('.euiTableRowCell')
.at(3)
.find('a').length
).toBe(1);
});
test('it has a comma separated list of hosts when the number of hosts is greater than 1', () => {
const wrapper = mount(
<TestProviders>
@ -107,11 +132,36 @@ describe('UncommonProcess Table Component', () => {
.find('.euiTableRow')
.at(2)
.find('.euiTableRowCell')
.at(5)
.at(3)
.text()
).toBe('Hostshello-world,hello-world-2 ');
});
test('it has 2 links when the number of hosts is equal to 2', () => {
const wrapper = mount(
<TestProviders>
<UncommonProcessTable
loading={false}
data={mockData.UncommonProcess.edges}
totalCount={mockData.UncommonProcess.totalCount}
hasNextPage={getOr(false, 'hasNextPage', mockData.UncommonProcess.pageInfo)!}
nextCursor={getOr(null, 'endCursor.value', mockData.UncommonProcess.pageInfo)}
loadMore={loadMore}
type={hostsModel.HostsType.page}
/>
</TestProviders>
);
expect(
wrapper
.find('.euiTableRow')
.at(2)
.find('.euiTableRowCell')
.at(3)
.find('a').length
).toBe(2);
});
test('it is empty when all hosts are invalid because they do not contain an id and a name', () => {
const wrapper = mount(
<TestProviders>
@ -131,11 +181,35 @@ describe('UncommonProcess Table Component', () => {
.find('.euiTableRow')
.at(3)
.find('.euiTableRowCell')
.at(5)
.at(3)
.text()
).toBe(`Hosts${getEmptyValue()}`);
});
test('it has no link when all hosts are invalid because they do not contain an id and a name', () => {
const wrapper = mount(
<TestProviders>
<UncommonProcessTable
loading={false}
data={mockData.UncommonProcess.edges}
totalCount={mockData.UncommonProcess.totalCount}
hasNextPage={getOr(false, 'hasNextPage', mockData.UncommonProcess.pageInfo)!}
nextCursor={getOr(null, 'endCursor.value', mockData.UncommonProcess.pageInfo)}
loadMore={loadMore}
type={hostsModel.HostsType.page}
/>
</TestProviders>
);
expect(
wrapper
.find('.euiTableRow')
.at(3)
.find('.euiTableRowCell')
.at(3)
.find('a').length
).toBe(0);
});
test('it is returns two hosts when others are invalid because they do not contain an id and a name', () => {
const wrapper = mount(
<TestProviders>
@ -155,7 +229,7 @@ describe('UncommonProcess Table Component', () => {
.find('.euiTableRow')
.at(4)
.find('.euiTableRowCell')
.at(5)
.at(3)
.text()
).toBe('Hostshello-world,hello-world-2 ');
});

View file

@ -13,6 +13,7 @@ import { hostsActions } from '../../../../store/actions';
import { UncommonProcessesEdges, UncommonProcessItem } from '../../../../graphql/types';
import { hostsModel, hostsSelectors, State } from '../../../../store';
import { defaultToEmptyTag, getEmptyValue } from '../../../empty_value';
import { HostDetailsLink } from '../../../links';
import { Columns, ItemsPerRow, LoadMoreTable } from '../../../load_more_table';
import * as i18n from './translations';
@ -130,14 +131,27 @@ const getUncommonColumns = (): [
}),
},
{
name: i18n.LAST_USER,
name: i18n.NUMBER_OF_HOSTS,
truncateText: false,
hideForMobile: false,
render: ({ node }) => <>{node.hosts != null ? node.hosts.length : getEmptyValue()}</>,
},
{
name: i18n.NUMBER_OF_INSTANCES,
truncateText: false,
hideForMobile: false,
render: ({ node }) => defaultToEmptyTag(node.instances),
},
{
name: i18n.HOSTS,
truncateText: false,
hideForMobile: false,
render: ({ node }) =>
getRowItemDraggables({
rowItems: node.user != null ? node.user.name : null,
attrName: 'user.name',
idPrefix: `uncommon-process-table-${node._id}-processUser`,
rowItems: getHostNames(node),
attrName: 'host.name',
idPrefix: `uncommon-process-table-${node._id}-processHost`,
render: item => <HostDetailsLink hostName={item} />,
}),
},
{
@ -153,26 +167,14 @@ const getUncommonColumns = (): [
}),
},
{
name: i18n.NUMBER_OF_INSTANCES,
truncateText: false,
hideForMobile: false,
render: ({ node }) => defaultToEmptyTag(node.instances),
},
{
name: i18n.NUMBER_OF_HOSTS,
truncateText: false,
hideForMobile: false,
render: ({ node }) => <>{node.hosts != null ? node.hosts.length : getEmptyValue()}</>,
},
{
name: i18n.HOSTS,
name: i18n.LAST_USER,
truncateText: false,
hideForMobile: false,
render: ({ node }) =>
getRowItemDraggables({
rowItems: getHostNames(node),
attrName: 'host.name',
idPrefix: `uncommon-process-table-${node._id}-processHost`,
rowItems: node.user != null ? node.user.name : null,
attrName: 'user.name',
idPrefix: `uncommon-process-table-${node._id}-processUser`,
}),
},
];

View file

@ -7,6 +7,7 @@
import { EuiDescriptionList, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { FlowTarget, IpOverviewData, Overview } from '../../../../graphql/types';
import { networkModel } from '../../../../store';
@ -40,10 +41,18 @@ interface OwnProps {
export type IpOverviewProps = OwnProps;
const DescriptionList = styled(EuiDescriptionList)`
${({ theme }) => `
dt {
font-size: ${theme.eui.euiFontSizeXS} !important;
}
`}
`;
const getDescriptionList = (descriptionList: DescriptionList[], key: number) => {
return (
<EuiFlexItem key={key}>
<EuiDescriptionList listItems={descriptionList} />
<DescriptionList listItems={descriptionList} />
</EuiFlexItem>
);
};
@ -73,7 +82,9 @@ export const IpOverview = pure<IpOverviewProps>(({ ip, data, loading, flowTarget
[
{
title: i18n.HOST_ID,
description: typeData ? hostIdRenderer(data.host, ip) : getEmptyTagValue(),
description: typeData
? hostIdRenderer({ host: data.host, ipFilter: ip })
: getEmptyTagValue(),
},
{
title: i18n.HOST_NAME,

View file

@ -42,18 +42,18 @@ export const WHOIS = i18n.translate('xpack.siem.network.ipDetails.ipOverview.who
});
export const VIEW_WHOIS = i18n.translate('xpack.siem.network.ipDetails.ipOverview.viewWhoisTitle', {
defaultMessage: 'View at iana.org',
defaultMessage: 'iana.org',
});
export const VIEW_VIRUS_TOTAL = i18n.translate(
'xpack.siem.network.ipDetails.ipOverview.viewVirusTotalTitle.',
{
defaultMessage: 'View at virustotal.com',
defaultMessage: 'virustotal.com',
}
);
export const VIEW_TALOS_INTELLIGENCE = i18n.translate(
'xpack.siem.network.ipDetails.ipOverview.viewTalosIntelligenceTitle',
{
defaultMessage: 'View at talosIntelligence.com',
defaultMessage: 'talosIntelligence.com',
}
);