[SIEM] Adds Machine Learning table anomalies, a pop over UI for anomalies, and machine learning details score (#39483)

## Summary

[SIEM] Adds Machine Learning table anomalies, a pop over UI for anomalies, and machine learning details score. Works on both the hosts page and the network page as well as the details sections of both those pages.

Table
<img width="760" alt="Screen Shot 2019-06-28 at 11 09 39 AM" src="https://user-images.githubusercontent.com/1151048/60359902-96c33e00-9997-11e9-8a0a-92fb8e7d808a.png">

Popover
<img width="764" alt="Screen Shot 2019-06-28 at 11 09 50 AM" src="https://user-images.githubusercontent.com/1151048/60359909-9cb91f00-9997-11e9-96e0-f51ec33cee6f.png">

Details with Popover:
<img width="800" alt="Screen Shot 2019-06-28 at 11 12 14 AM" src="https://user-images.githubusercontent.com/1151048/60359955-bce8de00-9997-11e9-8154-6165b21f25f7.png">


### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

- [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)
- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials
- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)

### For maintainers

- [x] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
- [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
This commit is contained in:
Frank Hassanabad 2019-06-29 01:20:15 -06:00 committed by GitHub
parent af56c9b732
commit 2bf06185da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 4634 additions and 306 deletions

View file

@ -4,8 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactNode } from 'react';
export type Pick3<T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2]> = {
[P1 in K1]: { [P2 in K2]: { [P3 in K3]: ((T[K1])[K2])[P3] } }
};
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// This type is for typing EuiDescriptionList
export interface DescriptionList {
title: NonNullable<ReactNode>;
description: NonNullable<ReactNode>;
}

View file

@ -99,14 +99,16 @@ interface BasicTableState {
paginationLoading: boolean;
}
export interface Columns<T> {
type Func<T> = (arg: T) => string | number;
export interface Columns<T, U = T> {
field?: string;
name: string | React.ReactNode;
isMobileHeader?: boolean;
sortable?: boolean;
sortable?: boolean | Func<T>;
truncateText?: boolean;
hideForMobile?: boolean;
render?: (item: T) => void;
render?: (item: T, node: U) => void;
width?: string;
}
@ -313,7 +315,7 @@ const FooterAction = styled.div`
* The getOr is just there to simplify the test
* So we do NOT need to wrap it around TestProvider
*/
const BackgroundRefetch = styled.div`
export const BackgroundRefetch = styled.div`
background-color: ${props => getOr('#ffffff', 'theme.eui.euiColorLightShade', props)};
margin: -5px;
height: calc(100% + 10px);

View file

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`create_influencers renders correctly against snapshot 1`] = `
<Connect(DraggableWrapperComponent)
dataProvider={
Object {
"and": Array [],
"enabled": true,
"excluded": false,
"id": "id-prefix-entity-name-entity-value",
"kqlQuery": "",
"name": "entity-value",
"queryMatch": Object {
"field": "entity-name",
"operator": ":",
"value": "entity-value",
},
}
}
key="id-prefix-entity-name-entity-value"
render={[Function]}
/>
`;

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;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { InfluencerInput, Anomalies } from '../types';
import { useAnomaliesTableData } from './use_anomalies_table_data';
interface ChildrenArgs {
isLoadingAnomaliesData: boolean;
anomaliesData: Anomalies | null;
}
interface Props {
influencers: InfluencerInput[] | null;
startDate: number;
endDate: number;
children: (args: ChildrenArgs) => React.ReactNode;
}
export const AnomalyTableProvider = React.memo<Props>(
({ influencers, startDate, endDate, children }) => {
const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({
influencers,
startDate,
endDate,
});
return <>{children({ isLoadingAnomaliesData, anomaliesData })}</>;
}
);

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { cloneDeep } from 'lodash/fp';
import { getIntervalFromAnomalies } from './get_interval_from_anomalies';
import { mockAnomalies } from '../mock';
describe('get_interval_from_anomalies', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('returns "day" if anomalies is null', () => {
const interval = getIntervalFromAnomalies(null);
expect(interval).toEqual('day');
});
test('returns normal interval from the mocks', () => {
anomalies.interval = 'month';
const interval = getIntervalFromAnomalies(anomalies);
expect(interval).toEqual('month');
});
});

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Anomalies } from '../types';
export const getIntervalFromAnomalies = (anomalies: Anomalies | null) => {
if (anomalies == null) {
return 'day';
} else {
return anomalies.interval;
}
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { cloneDeep } from 'lodash/fp';
import { getSizeFromAnomalies } from './get_size_from_anomalies';
import { mockAnomalies } from '../mock';
describe('get_size_from_anomalies', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('returns 0 if anomalies is null', () => {
const size = getSizeFromAnomalies(null);
expect(size).toEqual(0);
});
test('returns anomalies length', () => {
const size = getSizeFromAnomalies(anomalies);
expect(size).toEqual(2);
});
});

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Anomalies } from '../types';
export const getSizeFromAnomalies = (anomalies: Anomalies | null): number => {
if (anomalies == null) {
return 0;
} else {
return anomalies.anomalies.length;
}
};

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InfluencerInput } from '../types';
import { influencersToString } from './use_anomalies_table_data';
describe('use_anomalies_table_data', () => {
test('should return a reduced single influencer to string', () => {
const influencers: InfluencerInput[] = [
{
fieldName: 'field-1',
fieldValue: 'value-1',
},
];
const influencerString = influencersToString(influencers);
expect(influencerString).toEqual('field-1:value-1');
});
test('should return a two single influencers together in a string', () => {
const influencers: InfluencerInput[] = [
{
fieldName: 'field-1',
fieldValue: 'value-1',
},
{
fieldName: 'field-2',
fieldValue: 'value-2',
},
];
const influencerString = influencersToString(influencers);
expect(influencerString).toEqual('field-1:value-1field-2:value-2');
});
test('should return an empty string when the array is empty', () => {
const influencers: InfluencerInput[] = [];
const influencerString = influencersToString(influencers);
expect(influencerString).toEqual('');
});
test('should return an empty string when passed null', () => {
const influencerString = influencersToString(null);
expect(influencerString).toEqual('');
});
});

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useState, useEffect, useContext } from 'react';
import moment from 'moment-timezone';
import { anomaliesTableData } from '../api/anomalies_table_data';
import { InfluencerInput, Anomalies } from '../types';
import {
KibanaConfigContext,
AppKibanaFrameworkAdapter,
} from '../../../lib/adapters/framework/kibana_framework_adapter';
interface Args {
influencers: InfluencerInput[] | null;
endDate: number;
startDate: number;
threshold?: number;
skip?: boolean;
}
type Return = [boolean, Anomalies | null];
export const influencersToString = (influencers: InfluencerInput[] | null): string =>
influencers == null
? ''
: influencers.reduce((accum, item) => `${accum}${item.fieldName}:${item.fieldValue}`, '');
export const getTimeZone = (config: Partial<AppKibanaFrameworkAdapter>): string => {
if (config.dateFormatTz !== 'Browser' && config.dateFormatTz != null) {
return config.dateFormatTz;
} else if (config.dateFormatTz === 'Browser' && config.timezone != null) {
return config.timezone;
} else {
return moment.tz.guess();
}
};
export const useAnomaliesTableData = ({
influencers,
startDate,
endDate,
threshold = 0,
skip = false,
}: Args): Return => {
const [tableData, setTableData] = useState<Anomalies | null>(null);
const [loading, setLoading] = useState(true);
const config = useContext(KibanaConfigContext);
const fetchFunc = async (
influencersInput: InfluencerInput[] | null,
earliestMs: number,
latestMs: number
) => {
if (influencersInput != null && !skip) {
const data = await anomaliesTableData(
{
jobIds: [],
criteriaFields: [],
aggregationInterval: 'auto',
threshold,
earliestMs,
latestMs,
influencers: influencersInput,
dateFormatTz: getTimeZone(config),
maxRecords: 500,
maxExamples: 10,
},
{
'kbn-version': config.kbnVersion,
}
);
setTableData(data);
setLoading(false);
} else {
setTableData(null);
setLoading(true);
}
};
useEffect(
() => {
setLoading(true);
fetchFunc(influencers, startDate, endDate);
},
[influencersToString(influencers), startDate, endDate, skip]
);
return [loading, tableData];
};

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import chrome from 'ui/chrome';
import { Anomalies, InfluencerInput } from '../types';
export interface Body {
jobIds: string[];
criteriaFields: string[];
influencers: InfluencerInput[];
aggregationInterval: string;
threshold: number;
earliestMs: number;
latestMs: number;
dateFormatTz: string;
maxRecords: number;
maxExamples: number;
}
export const anomaliesTableData = async (
body: Body,
headers: Record<string, string | undefined>
): Promise<Anomalies> => {
try {
const response = await fetch('/api/ml/results/anomalies_table_data', {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify(body),
headers: {
'kbn-system-api': 'true',
'content-Type': 'application/json',
'kbn-xsrf': chrome.getXsrfToken(),
...headers,
},
});
return await response.json();
} catch (error) {
// TODO: Toaster error when this happens instead of returning empty data
const empty: Anomalies = {
anomalies: [],
interval: 'second',
};
return empty;
}
};

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import toJson from 'enzyme-to-json';
import { shallow, mount } from 'enzyme';
import { EntityDraggable } from './entity_draggable';
import { TestProviders } from '../../mock/test_providers';
describe('create_influencers', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<EntityDraggable idPrefix="id-prefix" entityName="entity-name" entityValue="entity-value" />
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('renders with entity name with entity value as text', () => {
const wrapper = mount(
<TestProviders>
<EntityDraggable idPrefix="id-prefix" entityName="entity-name" entityValue="entity-value" />
</TestProviders>
);
expect(wrapper.text()).toEqual('entity-name: “entity-value”');
});
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { DraggableWrapper, DragEffects } from '../drag_and_drop/draggable_wrapper';
import { IS_OPERATOR } from '../timeline/data_providers/data_provider';
import { Provider } from '../timeline/data_providers/provider';
import { escapeDataProviderId } from '../drag_and_drop/helpers';
interface Props {
idPrefix: string;
entityName: string;
entityValue: string;
}
export const EntityDraggable = React.memo<Props>(
({ idPrefix, entityName, entityValue }): JSX.Element => {
const id = escapeDataProviderId(`${idPrefix}-${entityName}-${entityValue}`);
return (
<DraggableWrapper
key={id}
dataProvider={{
and: [],
enabled: true,
id,
name: entityValue,
excluded: false,
kqlQuery: '',
queryMatch: {
field: entityName,
value: entityValue,
operator: IS_OPERATOR,
},
}}
render={(dataProvider, _, snapshot) =>
snapshot.isDragging ? (
<DragEffects>
<Provider dataProvider={dataProvider} />
</DragEffects>
) : (
<>{`${entityName}: “${entityValue}`}</>
)
}
/>
);
}
);

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`create_influencers renders correctly against snapshot 1`] = `
<span>
<EuiFlexItem
grow={false}
key="host.name: “zeek-iowa”"
>
host.name: “zeek-iowa”
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="process.name: “du”"
>
process.name: “du”
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="user.name: “root”"
>
user.name: “root”
</EuiFlexItem>
</span>
`;

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import toJson from 'enzyme-to-json';
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { shallow, mount } from 'enzyme';
import { createInfluencers, createKeyAndValue } from './create_influencers';
describe('create_influencers', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(<span>{createInfluencers(anomalies.anomalies[0])}</span>);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it returns expected createKeyAndValue record with special left and right quotes', () => {
const entities = createKeyAndValue({ 'name-1': 'value-1' });
expect(entities).toEqual('name-1: “value-1”');
});
test('it returns expected createKeyAndValue record when empty object is passed', () => {
const entities = createKeyAndValue({});
expect(entities).toEqual('');
});
test('it creates the anomalies without filtering anything out since they are all well formed', () => {
const wrapper = mount(<span>{createInfluencers(anomalies.anomalies[0])}</span>);
expect(wrapper.text()).toEqual('host.name: “zeek-iowa”process.name: “du”user.name: “root”');
});
test('it returns empty text when passed in empty objects of influencers', () => {
anomalies.anomalies[0].influencers = [{}, {}, {}];
const wrapper = mount(<span>{createInfluencers(anomalies.anomalies[0])}</span>);
expect(wrapper.text()).toEqual('');
});
test('it filters out empty anomalies but keeps the others', () => {
anomalies.anomalies[0].influencers = [
{ 'influencer-name-one': 'influencer-value-one' },
{},
{ 'influencer-name-two': 'influencer-value-two' },
];
const wrapper = mount(<span>{createInfluencers(anomalies.anomalies[0])}</span>);
expect(wrapper.text()).toEqual(
'influencer-name-one: “influencer-value-one”influencer-name-two: “influencer-value-two”'
);
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { isEmpty } from 'lodash/fp';
import { Anomaly } from '../types';
export const createKeyAndValue = (influencer: Record<string, string>): string => {
if (Object.keys(influencer)[0] != null && Object.values(influencer)[0] != null) {
return `${Object.keys(influencer)[0]}: “${Object.values(influencer)[0]}`;
} else {
return '';
}
};
export const createInfluencers = (score: Anomaly): JSX.Element => {
return (
<>
{score.influencers
.filter(influencer => !isEmpty(influencer))
.map(influencer => {
const keyAndValue = createKeyAndValue(influencer);
return (
<EuiFlexItem key={keyAndValue} grow={false}>
{keyAndValue}
</EuiFlexItem>
);
})}
</>
);
};

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { cloneDeep } from 'lodash/fp';
import { getHostNameFromInfluencers } from './get_host_name_from_influencers';
import { mockAnomalies } from '../mock';
describe('get_host_name_from_influencers', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('returns host names from influencers from the mock', () => {
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
expect(hostName).toEqual('zeek-iowa');
});
test('returns null if there are no influencers from the mock', () => {
anomalies.anomalies[0].influencers = [];
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
expect(hostName).toEqual(null);
});
test('returns null if there influencers is an empty object', () => {
anomalies.anomalies[0].influencers = [{}];
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
expect(hostName).toEqual(null);
});
test('returns host name mixed with other data', () => {
anomalies.anomalies[0].influencers = [{ 'host.name': 'name-1' }, { 'source.ip': '127.0.0.1' }];
const hostName = getHostNameFromInfluencers(anomalies.anomalies[0].influencers);
expect(hostName).toEqual('name-1');
});
});

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const getHostNameFromInfluencers = (
influencers: Array<Record<string, string>>
): string | null => {
const recordFound = influencers.find(influencer => {
const influencerName = Object.keys(influencer)[0];
if (influencerName === 'host.name') {
return true;
} else {
return false;
}
});
if (recordFound != null) {
return Object.values(recordFound)[0];
} else {
return null;
}
};

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { cloneDeep } from 'lodash/fp';
import { getNetworkFromInfluencers } from './get_network_from_influencers';
import { mockAnomalies } from '../mock';
import { DestinationOrSource } from '../types';
describe('get_network_from_influencers', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('returns null if there are no influencers from the mock', () => {
anomalies.anomalies[0].influencers = [];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
expect(network).toEqual(null);
});
test('returns null if the influencers is an empty object', () => {
anomalies.anomalies[0].influencers = [{}];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
expect(network).toEqual(null);
});
test('returns network name of source mixed with other data', () => {
anomalies.anomalies[0].influencers = [{ 'host.name': 'name-1' }, { 'source.ip': '127.0.0.1' }];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
const expected: { ip: string; type: DestinationOrSource } = {
ip: '127.0.0.1',
type: 'source.ip',
};
expect(network).toEqual(expected);
});
test('returns network name mixed with other data', () => {
anomalies.anomalies[0].influencers = [
{ 'host.name': 'name-1' },
{ 'destination.ip': '127.0.0.1' },
];
const network = getNetworkFromInfluencers(anomalies.anomalies[0].influencers);
const expected: { ip: string; type: DestinationOrSource } = {
ip: '127.0.0.1',
type: 'destination.ip',
};
expect(network).toEqual(expected);
});
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DestinationOrSource } from '../types';
export const getNetworkFromInfluencers = (
influencers: Array<Record<string, string>>
): { ip: string; type: DestinationOrSource } | null => {
const recordFound = influencers.find(influencer => {
const influencerName = Object.keys(influencer)[0];
if (influencerName === 'destination.ip' || influencerName === 'source.ip') {
return true;
} else {
return false;
}
});
if (recordFound != null) {
const influencerName = Object.keys(recordFound)[0];
if (influencerName === 'destination.ip' || influencerName === 'source.ip') {
return { ip: Object.values(recordFound)[0], type: influencerName };
} else {
// default to destination.ip
return { ip: Object.values(recordFound)[0], type: 'destination.ip' };
}
} else {
return null;
}
};

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HostItem } from '../../../graphql/types';
import { InfluencerInput } from '../types';
import { hostToInfluencers } from './host_to_influencers';
describe('host_to_influencer', () => {
test('converts a host to an influencer', () => {
const hostItem: HostItem = {
host: {
name: ['host-name'],
},
};
const expectedInfluencer: InfluencerInput[] = [
{
fieldName: 'host.name',
fieldValue: 'host-name',
},
];
expect(hostToInfluencers(hostItem)).toEqual(expectedInfluencer);
});
test('returns a null if the host.name is null', () => {
const hostItem: HostItem = {
host: {
name: null,
},
};
expect(hostToInfluencers(hostItem)).toEqual(null);
});
test('returns a null if the host is null', () => {
const hostItem: HostItem = {
host: null,
};
expect(hostToInfluencers(hostItem)).toEqual(null);
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InfluencerInput } from '../types';
import { HostItem } from '../../../graphql/types';
export const hostToInfluencers = (hostItem: HostItem): InfluencerInput[] | null => {
if (hostItem.host != null && hostItem.host.name != null) {
const influencers: InfluencerInput[] = [
{
fieldName: 'host.name',
fieldValue: hostItem.host.name[0],
},
];
return influencers;
} else {
return null;
}
};

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InfluencerInput } from '../types';
import { networkToInfluencers } from './network_to_influencers';
describe('network_to_influencers', () => {
test('converts a network to an influencer', () => {
const expectedInfluencer: InfluencerInput[] = [
{
fieldName: 'source.ip',
fieldValue: '127.0.0.1',
},
{
fieldName: 'destination.ip',
fieldValue: '127.0.0.1',
},
];
expect(networkToInfluencers('127.0.0.1')).toEqual(expectedInfluencer);
});
});

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InfluencerInput } from '../types';
export const networkToInfluencers = (ip: string): InfluencerInput[] => {
const influencers: InfluencerInput[] = [
{
fieldName: 'source.ip',
fieldValue: ip,
},
{
fieldName: 'destination.ip',
fieldValue: ip,
},
];
return influencers;
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { createExplorerLink } from './create_explorer_link';
describe('create_explorer_link', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('it returns expected link', () => {
const entities = createExplorerLink(
anomalies.anomalies[0],
new Date('1970').valueOf(),
new Date('3000').valueOf()
);
expect(entities).toEqual(
"ml#/explorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)"
);
});
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Anomaly } from '../types';
export const createExplorerLink = (score: Anomaly, startDate: number, endDate: number): string => {
const startDateIso = new Date(startDate).toISOString();
const endDateIso = new Date(endDate).toISOString();
const JOB_PREFIX = `ml#/explorer?_g=(ml:(jobIds:!(${score.jobId}))`;
const REFRESH_INTERVAL = `,refreshInterval:(display:Off,pause:!f,value:0),time:(from:'${startDateIso}',mode:absolute,to:'${endDateIso}'))`;
const INTERVAL_SELECTION = `&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)`;
return `${JOB_PREFIX}${REFRESH_INTERVAL}${INTERVAL_SELECTION}`;
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { createSeriesLink } from './create_series_link';
describe('create_series_link', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('it returns expected createKeyAndValue record with special left and right quotes', () => {
const entities = createSeriesLink(
anomalies.anomalies[0],
new Date('1970').valueOf(),
new Date('3000').valueOf()
);
expect(entities).toEqual(
"ml#/timeseriesexplorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(mlSelectInterval:(display:Auto,val:auto),mlSelectSeverity:(color:%23d2e9f7,display:warning,val:0),mlTimeSeriesExplorer:(detectorIndex:0,entities:(host.name:'zeek-iowa',process.name:'du',user.name:'root')))"
);
});
});

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createEntitiesFromScore } from '../score/create_entities_from_score';
import { Anomaly } from '../types';
export const createSeriesLink = (score: Anomaly, startDate: number, endDate: number): string => {
const startDateIso = new Date(startDate).toISOString();
const endDateIso = new Date(endDate).toISOString();
const JOB_PREFIX = `ml#/timeseriesexplorer?_g=(ml:(jobIds:!(${score.jobId}))`;
const REFRESH_INTERVAL = `,refreshInterval:(display:Off,pause:!f,value:0),time:(from:'${startDateIso}',mode:absolute,to:'${endDateIso}'))`;
const INTERVAL_SELECTION = `&_a=(mlSelectInterval:(display:Auto,val:auto),mlSelectSeverity:(color:%23d2e9f7,display:warning,val:0),mlTimeSeriesExplorer:(detectorIndex:0,`;
const ENTITIES = `entities:(${createEntitiesFromScore(score)})))`;
return `${JOB_PREFIX}${REFRESH_INTERVAL}${INTERVAL_SELECTION}${ENTITIES}`;
};

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Anomalies } from './types';
export const mockAnomalies: Anomalies = {
anomalies: [
{
time: new Date('2019-06-16T06:00:00.000Z').valueOf(),
source: {
job_id: 'job-1',
result_type: 'record',
probability: 0.024041164411288146,
multi_bucket_impact: 0,
record_score: 16.193669439507826,
initial_record_score: 16.193669439507826,
bucket_span: 900,
detector_index: 0,
is_interim: false,
timestamp: new Date('2019-06-16T06:00:00.000Z').valueOf(),
by_field_name: 'process.name',
by_field_value: 'du',
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
function: 'rare',
function_description: 'rare',
typical: [0.024041164411288146],
actual: [1],
influencers: [
{
influencer_field_name: 'user.name',
influencer_field_values: ['root'],
},
{
influencer_field_name: 'process.name',
influencer_field_values: ['du'],
},
{
influencer_field_name: 'host.name',
influencer_field_values: ['zeek-iowa'],
},
],
},
rowId: '1561157194802_0',
jobId: 'job-1',
detectorIndex: 0,
severity: 16.193669439507826,
entityName: 'process.name',
entityValue: 'du',
influencers: [
{
'host.name': 'zeek-iowa',
},
{
'process.name': 'du',
},
{
'user.name': 'root',
},
],
},
{
time: new Date('2019-06-16T06:00:00.000Z').valueOf(),
source: {
job_id: 'job-2',
result_type: 'record',
probability: 0.024041164411288146,
multi_bucket_impact: 0,
record_score: 16.193669439507826,
initial_record_score: 16.193669439507826,
bucket_span: 900,
detector_index: 0,
is_interim: false,
timestamp: new Date('2019-06-16T06:00:00.000Z').valueOf(),
by_field_name: 'process.name',
by_field_value: 'ls',
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
function: 'rare',
function_description: 'rare',
typical: [0.024041164411288146],
actual: [1],
influencers: [
{
influencer_field_name: 'user.name',
influencer_field_values: ['root'],
},
{
influencer_field_name: 'process.name',
influencer_field_values: ['ls'],
},
{
influencer_field_name: 'host.name',
influencer_field_values: ['zeek-iowa'],
},
],
},
rowId: '1561157194802_1',
jobId: 'job-2',
detectorIndex: 0,
severity: 16.193669439507826,
entityName: 'process.name',
entityValue: 'ls',
influencers: [
{
'host.name': 'zeek-iowa',
},
{
'process.name': 'ls',
},
{
'user.name': 'root',
},
],
},
],
interval: 'day',
};

View file

@ -0,0 +1,219 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`anomaly_scores renders correctly against snapshot 1`] = `
<Fragment>
<EuiFlexItem
grow={false}
>
<Memo()
id="anomaly-scores-job-key-1"
index={0}
score={
Object {
"detectorIndex": 0,
"entityName": "process.name",
"entityValue": "du",
"influencers": Array [
Object {
"host.name": "zeek-iowa",
},
Object {
"process.name": "du",
},
Object {
"user.name": "root",
},
],
"jobId": "job-1",
"rowId": "1561157194802_0",
"severity": 16.193669439507826,
"source": Object {
"actual": Array [
1,
],
"bucket_span": 900,
"by_field_name": "process.name",
"by_field_value": "du",
"detector_index": 0,
"function": "rare",
"function_description": "rare",
"influencers": Array [
Object {
"influencer_field_name": "user.name",
"influencer_field_values": Array [
"root",
],
},
Object {
"influencer_field_name": "process.name",
"influencer_field_values": Array [
"du",
],
},
Object {
"influencer_field_name": "host.name",
"influencer_field_values": Array [
"zeek-iowa",
],
},
],
"initial_record_score": 16.193669439507826,
"is_interim": false,
"job_id": "job-1",
"multi_bucket_impact": 0,
"partition_field_name": "host.name",
"partition_field_value": "zeek-iowa",
"probability": 0.024041164411288146,
"record_score": 16.193669439507826,
"result_type": "record",
"timestamp": 1560664800000,
"typical": Array [
0.024041164411288146,
],
},
"time": 1560664800000,
}
}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiPopover
anchorPosition="downCenter"
button={
<Styled(EuiIcon)
type="iInCircle"
/>
}
closePopover={[Function]}
data-test-subj="anomaly-score-popover"
hasArrow={true}
id="anomaly-score-popover"
isOpen={false}
onClick={[Function]}
ownFocus={false}
panelPaddingSize="m"
>
<EuiDescriptionList
data-test-subj="anomaly-description-list"
listItems={
Array [
Object {
"description": <React.Fragment>
<EuiSpacer
size="m"
/>
<Styled(EuiText)>
17
</Styled(EuiText)>
</React.Fragment>,
"title": "Max Anomaly Score",
},
Object {
"description": <EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
>
job-1
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiLink
color="primary"
href="ml#/explorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)"
target="_blank"
type="button"
>
View in Machine Learning
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>,
"title": <React.Fragment>
<EuiSpacer
size="m"
/>
Anomaly Job
</React.Fragment>,
},
Object {
"description": <EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
>
<pure(Component)
value={2019-06-16T06:00:00.000Z}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiLink
color="primary"
data-test-subj="anomaly-description-narrow-range-link"
onClick={[Function]}
target="_blank"
type="button"
>
Narrow to this date range
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>,
"title": "Detected",
},
Object {
"description": <EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
>
process.name: “du”
</EuiFlexItem>
</EuiFlexGroup>,
"title": "Top Anomaly Suspect",
},
Object {
"description": <EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<React.Fragment>
<EuiFlexItem
grow={false}
>
host.name: “zeek-iowa”
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
process.name: “du”
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
user.name: “root”
</EuiFlexItem>
</React.Fragment>
</EuiFlexGroup>,
"title": "Influenced By",
},
]
}
/>
</EuiPopover>
</EuiFlexItem>
</Fragment>
`;

View file

@ -0,0 +1,161 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`anomaly_scores renders correctly against snapshot 1`] = `
<Fragment>
<EuiFlexGroup
gutterSize="none"
responsive={false}
>
<Memo()
endDate={32503680000000}
index={0}
interval="day"
jobKey="job-1-16.193669439507826-process.name-du"
key="job-1-16.193669439507826-process.name-du"
narrowDateRange={[MockFunction]}
score={
Object {
"detectorIndex": 0,
"entityName": "process.name",
"entityValue": "du",
"influencers": Array [
Object {
"host.name": "zeek-iowa",
},
Object {
"process.name": "du",
},
Object {
"user.name": "root",
},
],
"jobId": "job-1",
"rowId": "1561157194802_0",
"severity": 16.193669439507826,
"source": Object {
"actual": Array [
1,
],
"bucket_span": 900,
"by_field_name": "process.name",
"by_field_value": "du",
"detector_index": 0,
"function": "rare",
"function_description": "rare",
"influencers": Array [
Object {
"influencer_field_name": "user.name",
"influencer_field_values": Array [
"root",
],
},
Object {
"influencer_field_name": "process.name",
"influencer_field_values": Array [
"du",
],
},
Object {
"influencer_field_name": "host.name",
"influencer_field_values": Array [
"zeek-iowa",
],
},
],
"initial_record_score": 16.193669439507826,
"is_interim": false,
"job_id": "job-1",
"multi_bucket_impact": 0,
"partition_field_name": "host.name",
"partition_field_value": "zeek-iowa",
"probability": 0.024041164411288146,
"record_score": 16.193669439507826,
"result_type": "record",
"timestamp": 1560664800000,
"typical": Array [
0.024041164411288146,
],
},
"time": 1560664800000,
}
}
startDate={0}
/>
<Memo()
endDate={32503680000000}
index={1}
interval="day"
jobKey="job-2-16.193669439507826-process.name-ls"
key="job-2-16.193669439507826-process.name-ls"
narrowDateRange={[MockFunction]}
score={
Object {
"detectorIndex": 0,
"entityName": "process.name",
"entityValue": "ls",
"influencers": Array [
Object {
"host.name": "zeek-iowa",
},
Object {
"process.name": "ls",
},
Object {
"user.name": "root",
},
],
"jobId": "job-2",
"rowId": "1561157194802_1",
"severity": 16.193669439507826,
"source": Object {
"actual": Array [
1,
],
"bucket_span": 900,
"by_field_name": "process.name",
"by_field_value": "ls",
"detector_index": 0,
"function": "rare",
"function_description": "rare",
"influencers": Array [
Object {
"influencer_field_name": "user.name",
"influencer_field_values": Array [
"root",
],
},
Object {
"influencer_field_name": "process.name",
"influencer_field_values": Array [
"ls",
],
},
Object {
"influencer_field_name": "host.name",
"influencer_field_values": Array [
"zeek-iowa",
],
},
],
"initial_record_score": 16.193669439507826,
"is_interim": false,
"job_id": "job-2",
"multi_bucket_impact": 0,
"partition_field_name": "host.name",
"partition_field_value": "zeek-iowa",
"probability": 0.024041164411288146,
"record_score": 16.193669439507826,
"result_type": "record",
"timestamp": 1560664800000,
"typical": Array [
0.024041164411288146,
],
},
"time": 1560664800000,
}
}
startDate={0}
/>
</EuiFlexGroup>
</Fragment>
`;

View file

@ -0,0 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`create_description_list renders correctly against snapshot 1`] = `
<dl
className="euiDescriptionList euiDescriptionList--row"
>
<EuiDescriptionListTitle
key="title-0"
>
Max Anomaly Score
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
key="description-0"
>
<EuiSpacer
size="m"
/>
<Styled(EuiText)>
17
</Styled(EuiText)>
</EuiDescriptionListDescription>
<EuiDescriptionListTitle
key="title-1"
>
<EuiSpacer
size="m"
/>
Anomaly Job
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
key="description-1"
>
<EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
>
job-1
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiLink
color="primary"
href="ml#/explorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)"
target="_blank"
type="button"
>
View in Machine Learning
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionListDescription>
<EuiDescriptionListTitle
key="title-2"
>
Detected
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
key="description-2"
>
<EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
>
<pure(Component)
value={2019-06-16T06:00:00.000Z}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiLink
color="primary"
data-test-subj="anomaly-description-narrow-range-link"
onClick={[Function]}
target="_blank"
type="button"
>
Narrow to this date range
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionListDescription>
<EuiDescriptionListTitle
key="title-3"
>
Top Anomaly Suspect
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
key="description-3"
>
<EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
>
process.name: “du”
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionListDescription>
<EuiDescriptionListTitle
key="title-4"
>
Influenced By
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
key="description-4"
>
<EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
key="host.name: “zeek-iowa”"
>
host.name: “zeek-iowa”
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="process.name: “du”"
>
process.name: “du”
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="user.name: “root”"
>
user.name: “root”
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionListDescription>
</dl>
`;

View file

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`draggable_score renders correctly against snapshot 1`] = `
<Connect(DraggableWrapperComponent)
dataProvider={
Object {
"and": Array [],
"enabled": true,
"excluded": false,
"id": "some-id",
"kqlQuery": "",
"name": "process.name",
"queryMatch": Object {
"field": "process.name",
"operator": ":",
"value": "du",
},
}
}
key="some-id"
render={[Function]}
/>
`;

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { cloneDeep } from 'lodash/fp';
import * as React from 'react';
import { AnomalyScore } from './anomaly_score';
import { mockAnomalies } from '../mock';
import { TestProviders } from '../../../mock/test_providers';
import { Anomalies } from '../types';
const endDate: number = new Date('3000-01-01T00:00:00.000Z').valueOf();
const narrowDateRange = jest.fn();
describe('anomaly_scores', () => {
let anomalies: Anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<AnomalyScore
jobKey="job-key-1"
startDate={0}
endDate={endDate}
score={anomalies.anomalies[0]}
interval="day"
narrowDateRange={narrowDateRange}
/>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('should not show a popover on initial render', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScore
jobKey="job-key-1"
startDate={0}
endDate={endDate}
score={anomalies.anomalies[0]}
interval="day"
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="anomaly-description-list"]').exists()).toEqual(false);
});
test('show a popover on a mouse click', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScore
jobKey="job-key-1"
startDate={0}
endDate={endDate}
score={anomalies.anomalies[0]}
interval="day"
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="anomaly-score-popover"]')
.first()
.simulate('click');
wrapper.update();
expect(wrapper.find('[data-test-subj="anomaly-description-list"]').exists()).toEqual(true);
});
});

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { EuiPopover, EuiDescriptionList, EuiFlexItem, EuiIcon } from '@elastic/eui';
import styled from 'styled-components';
import { NarrowDateRange, Anomaly } from '../types';
import { DraggableScore } from './draggable_score';
import { escapeDataProviderId } from '../../drag_and_drop/helpers';
import { createDescriptionList } from './create_description_list';
interface Args {
startDate: number;
endDate: number;
narrowDateRange: NarrowDateRange;
jobKey: string;
index?: number;
score: Anomaly;
interval: string;
}
const Icon = styled(EuiIcon)`
vertical-align: text-bottom;
cursor: pointer;
`;
export const AnomalyScore = React.memo<Args>(
({ jobKey, startDate, endDate, index = 0, score, interval, narrowDateRange }): JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<EuiFlexItem grow={false}>
<DraggableScore
id={escapeDataProviderId(`anomaly-scores-${jobKey}`)}
index={index}
score={score}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
data-test-subj="anomaly-score-popover"
id="anomaly-score-popover"
isOpen={isOpen}
onClick={() => setIsOpen(!isOpen)}
closePopover={() => setIsOpen(!isOpen)}
button={<Icon type="iInCircle" />}
>
<EuiDescriptionList
data-test-subj="anomaly-description-list"
listItems={createDescriptionList(
score,
startDate,
endDate,
interval,
narrowDateRange
)}
/>
</EuiPopover>
</EuiFlexItem>
</>
);
}
);

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { cloneDeep } from 'lodash/fp';
import * as React from 'react';
import { AnomalyScores, createJobKey } from './anomaly_scores';
import { mockAnomalies } from '../mock';
import { TestProviders } from '../../../mock/test_providers';
import { getEmptyValue } from '../../empty_value';
import { Anomalies } from '../types';
const endDate: number = new Date('3000-01-01T00:00:00.000Z').valueOf();
const narrowDateRange = jest.fn();
describe('anomaly_scores', () => {
let anomalies: Anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<AnomalyScores
anomalies={anomalies}
startDate={0}
endDate={endDate}
isLoading={false}
narrowDateRange={narrowDateRange}
/>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('renders spinner when isLoading is true is passed', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScores
anomalies={anomalies}
startDate={0}
endDate={endDate}
isLoading={true}
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="anomaly-score-spinner"]').exists()).toEqual(true);
});
test('does NOT render spinner when isLoading is false is passed', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScores
anomalies={anomalies}
startDate={0}
endDate={endDate}
isLoading={false}
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="anomaly-score-spinner"]').exists()).toEqual(false);
});
test('renders an empty value if anomalies is null', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScores
anomalies={null}
startDate={0}
endDate={endDate}
isLoading={false}
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
expect(wrapper.text()).toEqual(getEmptyValue());
});
test('renders an empty value if anomalies array is empty', () => {
anomalies.anomalies = [];
const wrapper = mount(
<TestProviders>
<AnomalyScores
anomalies={anomalies}
startDate={0}
endDate={endDate}
isLoading={false}
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
expect(wrapper.text()).toEqual(getEmptyValue());
});
test('can create a job key', () => {
const job = createJobKey(anomalies.anomalies[0]);
expect(job).toEqual('job-1-16.193669439507826-process.name-du');
});
test('should not show a popover on initial render', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScores
anomalies={anomalies}
startDate={0}
endDate={endDate}
isLoading={false}
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="anomaly-description-list"]').exists()).toEqual(false);
});
test('showing a popover on a mouse click', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScores
anomalies={anomalies}
startDate={0}
endDate={endDate}
isLoading={false}
narrowDateRange={narrowDateRange}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="anomaly-score-popover"]')
.first()
.simulate('click');
wrapper.update();
expect(wrapper.find('[data-test-subj="anomaly-description-list"]').exists()).toEqual(true);
});
});

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiLoadingSpinner, EuiFlexGroup } from '@elastic/eui';
import { getEmptyTagValue } from '../../empty_value';
import { Anomalies, Anomaly, NarrowDateRange } from '../types';
import { getTopSeverityJobs } from './get_top_severity';
import { AnomalyScore } from './anomaly_score';
interface Args {
startDate: number;
endDate: number;
anomalies: Anomalies | null;
isLoading: boolean;
narrowDateRange: NarrowDateRange;
limit?: number;
}
export const createJobKey = (score: Anomaly): string =>
`${score.jobId}-${score.severity}-${score.entityName}-${score.entityValue}`;
export const AnomalyScores = React.memo<Args>(
({ anomalies, startDate, endDate, isLoading, narrowDateRange, limit }): JSX.Element => {
if (isLoading) {
return <EuiLoadingSpinner data-test-subj="anomaly-score-spinner" size="m" />;
} else if (anomalies == null || anomalies.anomalies.length === 0) {
return getEmptyTagValue();
} else {
return (
<>
<EuiFlexGroup gutterSize="none" responsive={false}>
{getTopSeverityJobs(anomalies.anomalies, limit).map((score, index) => {
const jobKey = createJobKey(score);
return (
<AnomalyScore
key={jobKey}
jobKey={jobKey}
startDate={startDate}
endDate={endDate}
index={index}
score={score}
interval={anomalies.interval}
narrowDateRange={narrowDateRange}
/>
);
})}
</EuiFlexGroup>
</>
);
}
}
);

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiText, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { Anomaly, NarrowDateRange } from '../types';
import { getScoreString } from './get_score_string';
import { PreferenceFormattedDate } from '../../formatted_date';
import { createInfluencers } from './../influencers/create_influencers';
import { DescriptionList } from '../../../../common/utility_types';
import * as i18n from './translations';
import { createExplorerLink } from '../links/create_explorer_link';
const LargeScore = styled(EuiText)`
font-size: 45px;
font-weight: lighter;
`;
export const createDescriptionList = (
score: Anomaly,
startDate: number,
endDate: number,
interval: string,
narrowDateRange: NarrowDateRange
): DescriptionList[] => {
const descriptionList: DescriptionList[] = [
{
title: i18n.MAX_ANOMALY_SCORE,
description: (
<>
<EuiSpacer size="m" />
<LargeScore>{getScoreString(score.severity)}</LargeScore>
</>
),
},
{
title: (
<>
<EuiSpacer size="m" />
{i18n.ANOMALY_JOB}
</>
),
description: (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>{score.jobId}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink href={createExplorerLink(score, startDate, endDate)} target="_blank">
{i18n.VIEW_IN_MACHINE_LEARNING}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
),
},
{
title: i18n.DETECTED,
description: (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<PreferenceFormattedDate value={new Date(score.time)} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
data-test-subj="anomaly-description-narrow-range-link"
onClick={() => {
narrowDateRange(score, interval);
}}
target="_blank"
>
{i18n.NARROW_TO_THIS_DATE_RANGE}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
),
},
{
title: i18n.TOP_ANOMALY_SUSPECT,
description: (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>{`${score.entityName}: “${score.entityValue}`}</EuiFlexItem>
</EuiFlexGroup>
),
},
{
title: i18n.INFLUENCED_BY,
description: (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{createInfluencers(score)}
</EuiFlexGroup>
),
},
];
return descriptionList;
};

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow, mount } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { mockAnomalies } from '../mock';
import { createDescriptionList } from './create_description_list';
import { EuiDescriptionList } from '@elastic/eui';
import { Anomaly } from '../types';
const endDate: number = new Date('3000-01-01T00:00:00.000Z').valueOf();
describe('create_description_list', () => {
let narrowDateRange = jest.fn();
beforeEach(() => {
narrowDateRange = jest.fn();
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<EuiDescriptionList
listItems={createDescriptionList(
mockAnomalies.anomalies[0],
0,
endDate,
'hours',
narrowDateRange
)}
/>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it calls the narrow date range function on click', () => {
const wrapper = mount(
<EuiDescriptionList
listItems={createDescriptionList(
mockAnomalies.anomalies[0],
0,
endDate,
'hours',
narrowDateRange
)}
/>
);
wrapper
.find('[data-test-subj="anomaly-description-narrow-range-link"]')
.first()
.simulate('click');
wrapper.update();
expect(narrowDateRange.mock.calls.length).toBe(1);
});
test('it should the narrow date range with the score', () => {
const wrapper = mount(
<EuiDescriptionList
listItems={createDescriptionList(
mockAnomalies.anomalies[0],
0,
endDate,
'hours',
narrowDateRange
)}
/>
);
wrapper
.find('[data-test-subj="anomaly-description-narrow-range-link"]')
.first()
.simulate('click');
wrapper.update();
const expected: Anomaly = {
detectorIndex: 0,
entityName: 'process.name',
entityValue: 'du',
influencers: [
{ 'host.name': 'zeek-iowa' },
{ 'process.name': 'du' },
{ 'user.name': 'root' },
],
jobId: 'job-1',
rowId: '1561157194802_0',
severity: 16.193669439507826,
source: {
actual: [1],
bucket_span: 900,
by_field_name: 'process.name',
by_field_value: 'du',
detector_index: 0,
function: 'rare',
function_description: 'rare',
influencers: [
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
],
initial_record_score: 16.193669439507826,
is_interim: false,
job_id: 'job-1',
multi_bucket_impact: 0,
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
probability: 0.024041164411288146,
record_score: 16.193669439507826,
result_type: 'record',
timestamp: 1560664800000,
typical: [0.024041164411288146],
},
time: 1560664800000,
};
expect(narrowDateRange.mock.calls[0][0]).toEqual(expected);
});
test('it should call the narrow date range with the interval', () => {
const wrapper = mount(
<EuiDescriptionList
listItems={createDescriptionList(
mockAnomalies.anomalies[0],
0,
endDate,
'hours',
narrowDateRange
)}
/>
);
wrapper
.find('[data-test-subj="anomaly-description-narrow-range-link"]')
.first()
.simulate('click');
wrapper.update();
expect(narrowDateRange.mock.calls[0][1]).toEqual('hours');
});
});

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockAnomalies } from '../mock';
import {
createEntitiesFromScore,
createEntity,
createEntityFromRecord,
} from './create_entities_from_score';
import { cloneDeep } from 'lodash/fp';
describe('create_entities_from_score', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('it returns expected entities from a typical score', () => {
const entities = createEntitiesFromScore(anomalies.anomalies[0]);
expect(entities).toEqual("host.name:'zeek-iowa',process.name:'du',user.name:'root'");
});
test('it returns expected non-duplicate entities', () => {
const anomaly = anomalies.anomalies[0];
anomaly.entityName = 'name-1';
anomaly.entityValue = 'value-1';
anomaly.influencers = [
{
'name-1': 'value-1',
},
];
const entities = createEntitiesFromScore(anomaly);
expect(entities).toEqual("name-1:'value-1'");
});
test('it returns multiple entities', () => {
const anomaly = anomalies.anomalies[0];
anomaly.entityName = 'name-1';
anomaly.entityValue = 'value-1';
anomaly.influencers = [
{
'name-2': 'value-2',
},
];
const entities = createEntitiesFromScore(anomaly);
expect(entities).toEqual("name-2:'value-2',name-1:'value-1'");
});
test('it returns just a single entity with an empty set of influencers', () => {
const anomaly = anomalies.anomalies[0];
anomaly.entityName = 'name-1';
anomaly.entityValue = 'value-1';
anomaly.influencers = [];
const entities = createEntitiesFromScore(anomaly);
expect(entities).toEqual("name-1:'value-1'");
});
test('it creates a simple string entity with quotes', () => {
const entity = createEntity('name-1', 'value-1');
expect(entity).toEqual("name-1:'value-1'");
});
test('it creates a simple string entity from a record<string, string>', () => {
const entity = createEntityFromRecord({ 'name-1': 'value-1' });
expect(entity).toEqual("name-1:'value-1'");
});
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Anomaly } from '../types';
export const createEntityFromRecord = (entity: Record<string, string>): string =>
createEntity(Object.keys(entity)[0], Object.values(entity)[0]);
export const createEntity = (entityName: string, entityValue: string): string =>
`${entityName}:'${entityValue}'`;
export const createEntitiesFromScore = (score: Anomaly): string => {
const influencers = score.influencers.reduce((accum, item, index) => {
if (index === 0) {
return createEntityFromRecord(item);
} else {
return `${accum},${createEntityFromRecord(item)}`;
}
}, '');
if (influencers.length === 0) {
return createEntity(score.entityName, score.entityValue);
} else if (!influencers.includes(score.entityName)) {
return `${influencers},${createEntity(score.entityName, score.entityValue)}`;
} else {
return influencers;
}
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import toJson from 'enzyme-to-json';
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { shallow } from 'enzyme';
import { DraggableScore } from './draggable_score';
describe('draggable_score', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<DraggableScore id="some-id" index={0} score={anomalies.anomalies[0]} />
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { DraggableWrapper, DragEffects } from '../../drag_and_drop/draggable_wrapper';
import { Anomaly } from '../types';
import { IS_OPERATOR } from '../../timeline/data_providers/data_provider';
import { Provider } from '../../timeline/data_providers/provider';
import { Spacer } from '../../page';
import { getScoreString } from './get_score_string';
export const DraggableScore = React.memo<{
id: string;
index: number;
score: Anomaly;
}>(
({ id, index, score }): JSX.Element => (
<DraggableWrapper
key={id}
dataProvider={{
and: [],
enabled: true,
id,
name: score.entityName,
excluded: false,
kqlQuery: '',
queryMatch: {
field: score.entityName,
value: score.entityValue,
operator: IS_OPERATOR,
},
}}
render={(dataProvider, _, snapshot) =>
snapshot.isDragging ? (
<DragEffects>
<Provider dataProvider={dataProvider} />
</DragEffects>
) : (
<>
{index !== 0 && (
<>
{','}
<Spacer />
</>
)}
{getScoreString(score.severity)}
</>
)
}
/>
)
);

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getScoreString } from './get_score_string';
describe('create_influencers', () => {
test('it rounds up to 1 from 0.3', () => {
const score = getScoreString(0.3);
expect(score).toEqual('1');
});
test('it rounds up to 1 from 0.000000001', () => {
const score = getScoreString(0.000000001);
expect(score).toEqual('1');
});
test('0 is 0', () => {
const score = getScoreString(0);
expect(score).toEqual('0');
});
test('99.1 is 100', () => {
const score = getScoreString(99.1);
expect(score).toEqual('100');
});
test('100 is 100', () => {
const score = getScoreString(100);
expect(score).toEqual('100');
});
});

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const getScoreString = (score: number): string => String(Math.ceil(score));

View file

@ -0,0 +1,204 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { getTopSeverityJobs } from './get_top_severity';
describe('get_top_severity', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('gets the top severity jobs correctly on mock data', () => {
const topJobs = getTopSeverityJobs(anomalies.anomalies);
expect(topJobs).toEqual([
{
time: 1560664800000,
source: {
job_id: 'job-1',
result_type: 'record',
probability: 0.024041164411288146,
multi_bucket_impact: 0,
record_score: 16.193669439507826,
initial_record_score: 16.193669439507826,
bucket_span: 900,
detector_index: 0,
is_interim: false,
timestamp: 1560664800000,
by_field_name: 'process.name',
by_field_value: 'du',
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
function: 'rare',
function_description: 'rare',
typical: [0.024041164411288146],
actual: [1],
influencers: [
{
influencer_field_name: 'user.name',
influencer_field_values: ['root'],
},
{
influencer_field_name: 'process.name',
influencer_field_values: ['du'],
},
{
influencer_field_name: 'host.name',
influencer_field_values: ['zeek-iowa'],
},
],
},
rowId: '1561157194802_0',
jobId: 'job-1',
detectorIndex: 0,
severity: 16.193669439507826,
entityName: 'process.name',
entityValue: 'du',
influencers: [
{
'host.name': 'zeek-iowa',
},
{
'process.name': 'du',
},
{
'user.name': 'root',
},
],
},
{
time: 1560664800000,
source: {
job_id: 'job-2',
result_type: 'record',
probability: 0.024041164411288146,
multi_bucket_impact: 0,
record_score: 16.193669439507826,
initial_record_score: 16.193669439507826,
bucket_span: 900,
detector_index: 0,
is_interim: false,
timestamp: 1560664800000,
by_field_name: 'process.name',
by_field_value: 'ls',
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
function: 'rare',
function_description: 'rare',
typical: [0.024041164411288146],
actual: [1],
influencers: [
{
influencer_field_name: 'user.name',
influencer_field_values: ['root'],
},
{
influencer_field_name: 'process.name',
influencer_field_values: ['ls'],
},
{
influencer_field_name: 'host.name',
influencer_field_values: ['zeek-iowa'],
},
],
},
rowId: '1561157194802_1',
jobId: 'job-2',
detectorIndex: 0,
severity: 16.193669439507826,
entityName: 'process.name',
entityValue: 'ls',
influencers: [
{
'host.name': 'zeek-iowa',
},
{
'process.name': 'ls',
},
{
'user.name': 'root',
},
],
},
]);
});
test('gets the top severity jobs sorted by severity', () => {
anomalies.anomalies[0].severity = 0;
anomalies.anomalies[1].severity = 100;
const topJobs = getTopSeverityJobs(anomalies.anomalies);
expect(topJobs[0].severity).toEqual(100);
expect(topJobs[1].severity).toEqual(0);
});
test('removes duplicate job Ids', () => {
const anomaly = anomalies.anomalies[0];
anomalies.anomalies = [
cloneDeep(anomaly),
cloneDeep(anomaly),
cloneDeep(anomaly),
cloneDeep(anomaly),
];
const topJobs = getTopSeverityJobs(anomalies.anomalies);
expect(topJobs.length).toEqual(1);
});
test('preserves multiple job Ids', () => {
const anomaly = anomalies.anomalies[0];
const anomaly1 = cloneDeep(anomaly);
anomaly1.jobId = 'job-1';
const anomaly2 = cloneDeep(anomaly);
anomaly2.jobId = 'job-2';
const anomaly3 = cloneDeep(anomaly);
anomaly3.jobId = 'job-3';
const anomaly4 = cloneDeep(anomaly);
anomaly4.jobId = 'job-4';
anomalies.anomalies = [anomaly1, anomaly2, anomaly3, anomaly4];
const topJobs = getTopSeverityJobs(anomalies.anomalies);
expect(topJobs.length).toEqual(4);
});
test('will choose a job id which has a higher score', () => {
const anomaly = anomalies.anomalies[0];
const anomaly1 = cloneDeep(anomaly);
anomaly1.jobId = 'job-1';
anomaly1.severity = 100;
const anomaly2 = cloneDeep(anomaly);
anomaly2.jobId = 'job-2';
anomaly2.severity = 20; // This should not show since job-2 (25 below replaces this)
const anomaly3 = cloneDeep(anomaly);
anomaly3.jobId = 'job-3';
anomaly3.severity = 30;
const anomaly4 = cloneDeep(anomaly);
anomaly4.jobId = 'job-4';
anomaly4.severity = 10;
const anomaly5 = cloneDeep(anomaly);
anomaly5.jobId = 'job-2';
anomaly5.severity = 25; // This will replace job-2 (20 above)
anomalies.anomalies = [anomaly1, anomaly2, anomaly3, anomaly4, anomaly5];
const topJobs = getTopSeverityJobs(anomalies.anomalies);
expect(topJobs[0].severity).toEqual(100);
expect(topJobs[1].severity).toEqual(30);
expect(topJobs[2].severity).toEqual(25);
expect(topJobs[3].severity).toEqual(10);
});
test('it returns a top severity of empty length with an empty array', () => {
anomalies.anomalies = [];
const topJobs = getTopSeverityJobs(anomalies.anomalies);
expect(topJobs.length).toEqual(0);
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { toArray } from 'lodash/fp';
import { Anomaly } from '../types';
export const getTopSeverityJobs = (anomalies: Anomaly[], limit?: number): Anomaly[] => {
const reduced = anomalies.reduce<Record<string, Anomaly>>((accum, item) => {
const jobId = item.jobId;
const severity = item.severity;
if (accum[jobId] == null || accum[jobId].severity < severity) {
accum[jobId] = item;
}
return accum;
}, {});
const sortedArray = toArray(reduced).sort(
(anomalyA, anomalyB) => anomalyB.severity - anomalyA.severity
);
if (limit == null) {
return sortedArray;
} else {
return sortedArray.slice(0, limit);
}
};

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { cloneDeep } from 'lodash/fp';
import { mockAnomalies } from '../mock';
import { scoreIntervalToDateTime, FromTo } from './score_interval_to_datetime';
describe('score_interval_to_datetime', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('converts a second interval to plus or minus (+/-) one hour', () => {
const expected: FromTo = {
from: new Date('2019-06-25T04:31:59.345Z').valueOf(),
to: new Date('2019-06-25T06:31:59.345Z').valueOf(),
};
anomalies.anomalies[0].time = new Date('2019-06-25T05:31:59.345Z').valueOf();
expect(scoreIntervalToDateTime(anomalies.anomalies[0], 'second')).toEqual(expected);
});
test('converts a minute interval to plus or minus (+/-) one hour', () => {
const expected: FromTo = {
from: new Date('2019-06-25T04:31:59.345Z').valueOf(),
to: new Date('2019-06-25T06:31:59.345Z').valueOf(),
};
anomalies.anomalies[0].time = new Date('2019-06-25T05:31:59.345Z').valueOf();
expect(scoreIntervalToDateTime(anomalies.anomalies[0], 'minute')).toEqual(expected);
});
test('converts a hour interval to plus or minus (+/-) one hour', () => {
const expected: FromTo = {
from: new Date('2019-06-25T04:31:59.345Z').valueOf(),
to: new Date('2019-06-25T06:31:59.345Z').valueOf(),
};
anomalies.anomalies[0].time = new Date('2019-06-25T05:31:59.345Z').valueOf();
expect(scoreIntervalToDateTime(anomalies.anomalies[0], 'hour')).toEqual(expected);
});
test('converts a day interval to plus or minus (+/-) one day', () => {
const expected: FromTo = {
from: new Date('2019-06-24T05:31:59.345Z').valueOf(),
to: new Date('2019-06-26T05:31:59.345Z').valueOf(),
};
anomalies.anomalies[0].time = new Date('2019-06-25T05:31:59.345Z').valueOf();
expect(scoreIntervalToDateTime(anomalies.anomalies[0], 'day')).toEqual(expected);
});
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment';
import { Anomaly } from '../types';
export interface FromTo {
from: number;
to: number;
}
export const scoreIntervalToDateTime = (score: Anomaly, interval: string): FromTo => {
if (interval === 'second' || interval === 'minute' || interval === 'hour') {
return {
from: moment(score.time)
.subtract(1, 'hour')
.valueOf(),
to: moment(score.time)
.add(1, 'hour')
.valueOf(),
};
} else {
// default should be a day
return {
from: moment(score.time)
.subtract(1, 'day')
.valueOf(),
to: moment(score.time)
.add(1, 'day')
.valueOf(),
};
}
};

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const INFLUENCED_BY = i18n.translate('xpack.siem.ml.score.influencedByTitle', {
defaultMessage: 'Influenced By',
});
export const MAX_ANOMALY_SCORE = i18n.translate('xpack.siem.ml.score.maxAnomalyScoreTitle', {
defaultMessage: 'Max Anomaly Score',
});
export const ANOMALY_JOB = i18n.translate('xpack.siem.ml.score.anomalyJobTitle', {
defaultMessage: 'Anomaly Job',
});
export const VIEW_IN_MACHINE_LEARNING = i18n.translate(
'xpack.siem.ml.score.viewInMachineLearningLink',
{
defaultMessage: 'View in Machine Learning',
}
);
export const DETECTED = i18n.translate('xpack.siem.ml.score.detectedTitle', {
defaultMessage: 'Detected',
});
export const NARROW_TO_THIS_DATE_RANGE = i18n.translate(
'xpack.siem.ml.score.narrowToThisDateRangeLink',
{
defaultMessage: 'Narrow to this date range',
}
);
export const TOP_ANOMALY_SUSPECT = i18n.translate('xpack.siem.ml.score.topAnomalySuspectTitle', {
defaultMessage: 'Top Anomaly Suspect',
});

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiInMemoryTable, EuiPanel } from '@elastic/eui';
import styled from 'styled-components';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderPanel } from '../../header_panel';
import * as i18n from './translations';
import { getAnomaliesHostTableColumns } from './get_anomalies_host_table_columns';
import { convertAnomaliesToHosts } from './convert_anomalies_to_hosts';
import { BackgroundRefetch } from '../../load_more_table';
import { LoadingPanel } from '../../loading';
import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies';
import { getSizeFromAnomalies } from '../anomaly/get_size_from_anomalies';
import { dateTimesAreEqual } from './date_time_equality';
import { AnomaliesTableProps } from '../types';
const BasicTableContainer = styled.div`
position: relative;
`;
const sorting = {
sort: {
field: 'anomaly.severity',
direction: 'desc',
},
};
export const AnomaliesHostTable = React.memo<AnomaliesTableProps>(
({ startDate, endDate, narrowDateRange, skip }): JSX.Element => {
const [loading, tableData] = useAnomaliesTableData({
influencers: [],
startDate,
endDate,
threshold: 0,
skip,
});
const hosts = convertAnomaliesToHosts(tableData);
const interval = getIntervalFromAnomalies(tableData);
const columns = getAnomaliesHostTableColumns(startDate, endDate, interval, narrowDateRange);
const pagination = {
pageIndex: 0,
pageSize: 10,
totalItemCount: getSizeFromAnomalies(tableData),
pageSizeOptions: [5, 10, 20, 50],
hidePerPageOptions: false,
};
return (
<EuiPanel>
<BasicTableContainer>
{loading && (
<>
<BackgroundRefetch />
<LoadingPanel
height="100%"
width="100%"
text={`${i18n.LOADING} ${i18n.ANOMALIES}`}
position="absolute"
zIndex={3}
data-test-subj="anomalies-host-table-loading-panel"
/>
</>
)}
<HeaderPanel
subtitle={`${i18n.SHOWING}: ${hosts.length.toLocaleString()} ${i18n.ANOMALIES}`}
title={i18n.ANOMALIES}
/>
<EuiInMemoryTable
items={hosts}
columns={columns}
pagination={pagination}
sorting={sorting}
/>
</BasicTableContainer>
</EuiPanel>
);
},
dateTimesAreEqual
);

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiInMemoryTable, EuiPanel } from '@elastic/eui';
import styled from 'styled-components';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderPanel } from '../../header_panel';
import * as i18n from './translations';
import { convertAnomaliesToNetwork } from './convert_anomalies_to_network';
import { BackgroundRefetch } from '../../load_more_table';
import { LoadingPanel } from '../../loading';
import { AnomaliesTableProps } from '../types';
import { getAnomaliesNetworkTableColumns } from './get_anomalies_network_table_columns';
import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies';
import { getSizeFromAnomalies } from '../anomaly/get_size_from_anomalies';
import { dateTimesAreEqual } from './date_time_equality';
const BasicTableContainer = styled.div`
position: relative;
`;
const sorting = {
sort: {
field: 'anomaly.severity',
direction: 'desc',
},
};
export const AnomaliesNetworkTable = React.memo<AnomaliesTableProps>(
({ startDate, endDate, narrowDateRange, skip }): JSX.Element => {
const [loading, tableData] = useAnomaliesTableData({
influencers: [],
startDate,
endDate,
threshold: 0,
skip,
});
const networks = convertAnomaliesToNetwork(tableData);
const interval = getIntervalFromAnomalies(tableData);
const columns = getAnomaliesNetworkTableColumns(startDate, endDate, interval, narrowDateRange);
const pagination = {
pageIndex: 0,
pageSize: 10,
totalItemCount: getSizeFromAnomalies(tableData),
pageSizeOptions: [5, 10, 20, 50],
hidePerPageOptions: false,
};
return (
<EuiPanel>
<BasicTableContainer>
{loading && (
<>
<BackgroundRefetch />
<LoadingPanel
height="100%"
width="100%"
text={`${i18n.LOADING} ${i18n.ANOMALIES}`}
position="absolute"
zIndex={3}
data-test-subj="anomalies-network-table-loading-panel"
/>
</>
)}
<HeaderPanel
subtitle={`${i18n.SHOWING}: ${networks.length.toLocaleString()} ${i18n.ANOMALIES}`}
title={i18n.ANOMALIES}
/>
<EuiInMemoryTable
items={networks}
columns={columns}
pagination={pagination}
sorting={sorting}
/>
</BasicTableContainer>
</EuiPanel>
);
},
dateTimesAreEqual
);

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { convertAnomaliesToHosts } from './convert_anomalies_to_hosts';
import { AnomaliesByHost } from '../types';
describe('convert_anomalies_to_hosts', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('it returns expected anomalies from a host', () => {
const entities = convertAnomaliesToHosts(anomalies);
const expected: AnomaliesByHost[] = [
{
anomaly: {
detectorIndex: 0,
entityName: 'process.name',
entityValue: 'du',
influencers: [
{ 'host.name': 'zeek-iowa' },
{ 'process.name': 'du' },
{ 'user.name': 'root' },
],
jobId: 'job-1',
rowId: '1561157194802_0',
severity: 16.193669439507826,
source: {
actual: [1],
bucket_span: 900,
by_field_name: 'process.name',
by_field_value: 'du',
detector_index: 0,
function: 'rare',
function_description: 'rare',
influencers: [
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
],
initial_record_score: 16.193669439507826,
is_interim: false,
job_id: 'job-1',
multi_bucket_impact: 0,
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
probability: 0.024041164411288146,
record_score: 16.193669439507826,
result_type: 'record',
timestamp: 1560664800000,
typical: [0.024041164411288146],
},
time: 1560664800000,
},
hostName: 'zeek-iowa',
},
{
anomaly: {
detectorIndex: 0,
entityName: 'process.name',
entityValue: 'ls',
influencers: [
{ 'host.name': 'zeek-iowa' },
{ 'process.name': 'ls' },
{ 'user.name': 'root' },
],
jobId: 'job-2',
rowId: '1561157194802_1',
severity: 16.193669439507826,
source: {
actual: [1],
bucket_span: 900,
by_field_name: 'process.name',
by_field_value: 'ls',
detector_index: 0,
function: 'rare',
function_description: 'rare',
influencers: [
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
{ influencer_field_name: 'process.name', influencer_field_values: ['ls'] },
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
],
initial_record_score: 16.193669439507826,
is_interim: false,
job_id: 'job-2',
multi_bucket_impact: 0,
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
probability: 0.024041164411288146,
record_score: 16.193669439507826,
result_type: 'record',
timestamp: 1560664800000,
typical: [0.024041164411288146],
},
time: 1560664800000,
},
hostName: 'zeek-iowa',
},
];
expect(entities).toEqual(expected);
});
test('it returns empty anomalies if sent in a null', () => {
const entities = convertAnomaliesToHosts(null);
const expected: AnomaliesByHost[] = [];
expect(entities).toEqual(expected);
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Anomalies, AnomaliesByHost } from '../types';
import { getHostNameFromInfluencers } from '../influencers/get_host_name_from_influencers';
export const convertAnomaliesToHosts = (anomalies: Anomalies | null): AnomaliesByHost[] => {
if (anomalies == null) {
return [];
} else {
return anomalies.anomalies.reduce<AnomaliesByHost[]>((accum, item) => {
if (item.entityName === 'host.name') {
return accum.concat({ hostName: item.entityValue, anomaly: item });
} else {
const hostName = getHostNameFromInfluencers(item.influencers);
if (hostName != null) {
return accum.concat({ hostName, anomaly: item });
} else {
return accum;
}
}
}, []);
}
};

View file

@ -0,0 +1,231 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { convertAnomaliesToNetwork } from './convert_anomalies_to_network';
import { AnomaliesByHost, AnomaliesByNetwork } from '../types';
describe('convert_anomalies_to_hosts', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('it returns expected anomalies from a network if is part of the entityName and is a source.ip', () => {
anomalies.anomalies[0].entityName = 'source.ip';
anomalies.anomalies[0].entityValue = '127.0.0.1';
const entities = convertAnomaliesToNetwork(anomalies);
const expected: AnomaliesByNetwork[] = [
{
anomaly: {
detectorIndex: 0,
entityName: 'source.ip',
entityValue: '127.0.0.1',
influencers: [
{ 'host.name': 'zeek-iowa' },
{ 'process.name': 'du' },
{ 'user.name': 'root' },
],
jobId: 'job-1',
rowId: '1561157194802_0',
severity: 16.193669439507826,
source: {
actual: [1],
bucket_span: 900,
by_field_name: 'process.name',
by_field_value: 'du',
detector_index: 0,
function: 'rare',
function_description: 'rare',
influencers: [
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
],
initial_record_score: 16.193669439507826,
is_interim: false,
job_id: 'job-1',
multi_bucket_impact: 0,
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
probability: 0.024041164411288146,
record_score: 16.193669439507826,
result_type: 'record',
timestamp: 1560664800000,
typical: [0.024041164411288146],
},
time: 1560664800000,
},
ip: '127.0.0.1',
type: 'source.ip',
},
];
expect(entities).toEqual(expected);
});
test('it returns expected anomalies from a network if is part of the entityName and is a destination.ip', () => {
anomalies.anomalies[0].entityName = 'destination.ip';
anomalies.anomalies[0].entityValue = '127.0.0.1';
const entities = convertAnomaliesToNetwork(anomalies);
const expected: AnomaliesByNetwork[] = [
{
anomaly: {
detectorIndex: 0,
entityName: 'destination.ip',
entityValue: '127.0.0.1',
influencers: [
{ 'host.name': 'zeek-iowa' },
{ 'process.name': 'du' },
{ 'user.name': 'root' },
],
jobId: 'job-1',
rowId: '1561157194802_0',
severity: 16.193669439507826,
source: {
actual: [1],
bucket_span: 900,
by_field_name: 'process.name',
by_field_value: 'du',
detector_index: 0,
function: 'rare',
function_description: 'rare',
influencers: [
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
],
initial_record_score: 16.193669439507826,
is_interim: false,
job_id: 'job-1',
multi_bucket_impact: 0,
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
probability: 0.024041164411288146,
record_score: 16.193669439507826,
result_type: 'record',
timestamp: 1560664800000,
typical: [0.024041164411288146],
},
time: 1560664800000,
},
ip: '127.0.0.1',
type: 'destination.ip',
},
];
expect(entities).toEqual(expected);
});
test('it returns expected anomalies from a network if is part of the influencers and is a source.ip', () => {
anomalies.anomalies[0].entityName = 'not-an-ip';
anomalies.anomalies[0].entityValue = 'not-an-ip';
anomalies.anomalies[0].influencers = [{ 'source.ip': '127.0.0.1' }];
const entities = convertAnomaliesToNetwork(anomalies);
const expected: AnomaliesByNetwork[] = [
{
anomaly: {
detectorIndex: 0,
entityName: 'not-an-ip',
entityValue: 'not-an-ip',
influencers: [{ 'source.ip': '127.0.0.1' }],
jobId: 'job-1',
rowId: '1561157194802_0',
severity: 16.193669439507826,
source: {
actual: [1],
bucket_span: 900,
by_field_name: 'process.name',
by_field_value: 'du',
detector_index: 0,
function: 'rare',
function_description: 'rare',
influencers: [
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
],
initial_record_score: 16.193669439507826,
is_interim: false,
job_id: 'job-1',
multi_bucket_impact: 0,
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
probability: 0.024041164411288146,
record_score: 16.193669439507826,
result_type: 'record',
timestamp: 1560664800000,
typical: [0.024041164411288146],
},
time: 1560664800000,
},
ip: '127.0.0.1',
type: 'source.ip',
},
];
expect(entities).toEqual(expected);
});
test('it returns expected anomalies from a network if is part of the influencers and is a destination.ip', () => {
anomalies.anomalies[0].entityName = 'not-an-ip';
anomalies.anomalies[0].entityValue = 'not-an-ip';
anomalies.anomalies[0].influencers = [{ 'destination.ip': '127.0.0.1' }];
const entities = convertAnomaliesToNetwork(anomalies);
const expected: AnomaliesByNetwork[] = [
{
anomaly: {
detectorIndex: 0,
entityName: 'not-an-ip',
entityValue: 'not-an-ip',
influencers: [{ 'destination.ip': '127.0.0.1' }],
jobId: 'job-1',
rowId: '1561157194802_0',
severity: 16.193669439507826,
source: {
actual: [1],
bucket_span: 900,
by_field_name: 'process.name',
by_field_value: 'du',
detector_index: 0,
function: 'rare',
function_description: 'rare',
influencers: [
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
],
initial_record_score: 16.193669439507826,
is_interim: false,
job_id: 'job-1',
multi_bucket_impact: 0,
partition_field_name: 'host.name',
partition_field_value: 'zeek-iowa',
probability: 0.024041164411288146,
record_score: 16.193669439507826,
result_type: 'record',
timestamp: 1560664800000,
typical: [0.024041164411288146],
},
time: 1560664800000,
},
ip: '127.0.0.1',
type: 'destination.ip',
},
];
expect(entities).toEqual(expected);
});
test('it returns empty anomalies if sent in a null', () => {
const entities = convertAnomaliesToNetwork(null);
const expected: AnomaliesByHost[] = [];
expect(entities).toEqual(expected);
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Anomalies, AnomaliesByNetwork } from '../types';
import { getNetworkFromInfluencers } from '../influencers/get_network_from_influencers';
export const convertAnomaliesToNetwork = (anomalies: Anomalies | null): AnomaliesByNetwork[] => {
if (anomalies == null) {
return [];
} else {
return anomalies.anomalies.reduce<AnomaliesByNetwork[]>((accum, item) => {
if (item.entityName === 'source.ip' || item.entityName === 'destination.ip') {
return accum.concat({ ip: item.entityValue, type: item.entityName, anomaly: item });
} else {
const network = getNetworkFromInfluencers(item.influencers);
if (network != null) {
return accum.concat({ ip: network.ip, type: network.type, anomaly: item });
} else {
return accum;
}
}
}, []);
}
};

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockAnomalies } from '../mock';
import { cloneDeep } from 'lodash/fp';
import { createCompoundHostKey, createCompoundNetworkKey } from './create_compound_key';
import { AnomaliesByHost, AnomaliesByNetwork } from '../types';
describe('create_explorer_link', () => {
let anomalies = cloneDeep(mockAnomalies);
beforeEach(() => {
anomalies = cloneDeep(mockAnomalies);
});
test('it creates a compound host key', () => {
const anomaliesByHost: AnomaliesByHost = {
hostName: 'some-host-name',
anomaly: anomalies.anomalies[0],
};
const key = createCompoundHostKey(anomaliesByHost);
expect(key).toEqual('some-host-name-process.name-du-16.193669439507826-job-1');
});
test('it creates a compound network key', () => {
const anomaliesByNetwork: AnomaliesByNetwork = {
type: 'destination.ip',
ip: '127.0.0.1',
anomaly: anomalies.anomalies[0],
};
const key = createCompoundNetworkKey(anomaliesByNetwork);
expect(key).toEqual('127.0.0.1-process.name-du-16.193669439507826-job-1');
});
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AnomaliesByHost, AnomaliesByNetwork } from '../types';
export const createCompoundHostKey = (anomaliesByHost: AnomaliesByHost): string => {
return `${anomaliesByHost.hostName}-${anomaliesByHost.anomaly.entityName}-${
anomaliesByHost.anomaly.entityValue
}-${anomaliesByHost.anomaly.severity}-${anomaliesByHost.anomaly.jobId}`;
};
export const createCompoundNetworkKey = (anomaliesByNetwork: AnomaliesByNetwork): string => {
return `${anomaliesByNetwork.ip}-${anomaliesByNetwork.anomaly.entityName}-${
anomaliesByNetwork.anomaly.entityValue
}-${anomaliesByNetwork.anomaly.severity}-${anomaliesByNetwork.anomaly.jobId}`;
};

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { dateTimesAreEqual } from './date_time_equality';
import { AnomaliesTableProps } from '../types';
describe('date_time_equality', () => {
test('it returns true if start and end date are equal', () => {
const prev: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const next: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const equal = dateTimesAreEqual(prev, next);
expect(equal).toEqual(true);
});
test('it returns false if starts are not equal', () => {
const prev: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('1999').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const next: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const equal = dateTimesAreEqual(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if starts are not equal for next', () => {
const prev: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const next: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('1999').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const equal = dateTimesAreEqual(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if ends are not equal', () => {
const prev: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2001').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const next: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const equal = dateTimesAreEqual(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if ends are not equal for next', () => {
const prev: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const next: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2001').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const equal = dateTimesAreEqual(prev, next);
expect(equal).toEqual(false);
});
test('it returns false if skip is not equal', () => {
const prev: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: true,
};
const next: AnomaliesTableProps = {
startDate: new Date('2000').valueOf(),
endDate: new Date('2000').valueOf(),
narrowDateRange: jest.fn(),
skip: false,
};
const equal = dateTimesAreEqual(prev, next);
expect(equal).toEqual(false);
});
});

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AnomaliesTableProps } from '../types';
export const dateTimesAreEqual = (
prevProps: AnomaliesTableProps,
nextProps: AnomaliesTableProps
): boolean =>
prevProps.startDate === nextProps.startDate &&
prevProps.endDate === nextProps.endDate &&
prevProps.skip === nextProps.skip;

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Columns } from '../../load_more_table';
import { AnomaliesByHost, Anomaly, NarrowDateRange } from '../types';
import { getRowItemDraggable } from '../../tables/helpers';
import { EntityDraggable } from '../entity_draggable';
import { createCompoundHostKey } from './create_compound_key';
import { HostDetailsLink } from '../../links';
import * as i18n from './translations';
import { AnomalyScore } from '../score/anomaly_score';
export const getAnomaliesHostTableColumns = (
startDate: number,
endDate: number,
interval: string,
narrowDateRange: NarrowDateRange
): [
Columns<AnomaliesByHost['hostName'], AnomaliesByHost>,
Columns<Anomaly['severity'], AnomaliesByHost>,
Columns<Anomaly['entityValue'], AnomaliesByHost>,
Columns<Anomaly['influencers'], AnomaliesByHost>,
Columns<Anomaly['jobId']>
] => [
{
name: i18n.HOST_NAME,
field: 'hostName',
sortable: true,
render: (hostName, anomaliesByHost) =>
getRowItemDraggable({
rowItem: hostName,
attrName: 'host.name',
idPrefix: `anomalies-host-table-hostName-${createCompoundHostKey(
anomaliesByHost
)}-hostName`,
render: item => <HostDetailsLink hostName={item} />,
}),
},
{
name: i18n.SCORE,
field: 'anomaly.severity',
sortable: true,
render: (_, anomaliesByHost) => (
<AnomalyScore
startDate={startDate}
endDate={endDate}
jobKey={`anomalies-host-table-severity-${createCompoundHostKey(anomaliesByHost)}`}
narrowDateRange={narrowDateRange}
interval={interval}
score={anomaliesByHost.anomaly}
/>
),
},
{
name: i18n.ENTITY,
field: 'anomaly.entityValue',
sortable: true,
render: (entityValue, anomaliesByHost) => (
<EntityDraggable
idPrefix={`anomalies-host-table-entityValue${createCompoundHostKey(
anomaliesByHost
)}-entity`}
entityName={anomaliesByHost.anomaly.entityName}
entityValue={entityValue}
/>
),
},
{
name: i18n.INFLUENCED_BY,
field: 'anomaly.influencers',
render: (influencers, anomaliesByHost) => (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{influencers.map(influencer => {
const entityName = Object.keys(influencer)[0];
const entityValue = Object.values(influencer)[0];
return (
<EuiFlexItem
key={`${entityName}-${entityValue}-${createCompoundHostKey(anomaliesByHost)}`}
grow={false}
>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EntityDraggable
idPrefix={`anomalies-host-table-influencers-${entityName}-${entityValue}-${createCompoundHostKey(
anomaliesByHost
)}`}
entityName={entityName}
entityValue={entityValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
),
},
{
name: i18n.DETECTOR,
field: 'anomaly.jobId',
sortable: true,
},
];

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Columns } from '../../load_more_table';
import { Anomaly, NarrowDateRange, AnomaliesByNetwork } from '../types';
import { getRowItemDraggable } from '../../tables/helpers';
import { EntityDraggable } from '../entity_draggable';
import { createCompoundNetworkKey } from './create_compound_key';
import { IPDetailsLink } from '../../links';
import * as i18n from './translations';
import { AnomalyScore } from '../score/anomaly_score';
export const getAnomaliesNetworkTableColumns = (
startDate: number,
endDate: number,
interval: string,
narrowDateRange: NarrowDateRange
): [
Columns<AnomaliesByNetwork['ip'], AnomaliesByNetwork>,
Columns<Anomaly['severity'], AnomaliesByNetwork>,
Columns<Anomaly['entityValue'], AnomaliesByNetwork>,
Columns<Anomaly['influencers'], AnomaliesByNetwork>,
Columns<Anomaly['jobId']>
] => [
{
name: i18n.NETWORK_NAME,
field: 'ip',
sortable: true,
render: (ip, anomaliesByNetwork) =>
getRowItemDraggable({
rowItem: ip,
attrName: anomaliesByNetwork.type,
idPrefix: `anomalies-network-table-ip-${createCompoundNetworkKey(anomaliesByNetwork)}`,
render: item => <IPDetailsLink ip={item} />,
}),
},
{
name: i18n.SCORE,
field: 'anomaly.severity',
sortable: true,
render: (_, anomaliesByNetwork) => (
<AnomalyScore
startDate={startDate}
endDate={endDate}
jobKey={`anomalies-network-table-severity-${createCompoundNetworkKey(anomaliesByNetwork)}`}
narrowDateRange={narrowDateRange}
interval={interval}
score={anomaliesByNetwork.anomaly}
/>
),
},
{
name: i18n.ENTITY,
field: 'anomaly.entityValue',
sortable: true,
render: (entityValue, anomaliesByNetwork) => (
<EntityDraggable
idPrefix={`anomalies-network-table-entityValue-${createCompoundNetworkKey(
anomaliesByNetwork
)}`}
entityName={anomaliesByNetwork.anomaly.entityName}
entityValue={entityValue}
/>
),
},
{
name: i18n.INFLUENCED_BY,
field: 'anomaly.influencers',
render: (influencers, anomaliesByNetwork) => (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{influencers.map(influencer => {
const entityName = Object.keys(influencer)[0];
const entityValue = Object.values(influencer)[0];
return (
<EuiFlexItem
key={`${entityName}-${entityValue}-${createCompoundNetworkKey(anomaliesByNetwork)}`}
grow={false}
>
<EntityDraggable
idPrefix={`anomalies-network-table-influencers-${entityName}-${entityValue}-${createCompoundNetworkKey(
anomaliesByNetwork
)}`}
entityName={entityName}
entityValue={entityValue}
/>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
),
},
{
name: i18n.DETECTOR,
field: 'anomaly.jobId',
sortable: true,
},
];

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const SHOWING = i18n.translate('xpack.siem.anomaliesTable.table.showingDescription', {
defaultMessage: 'Showing',
});
export const ANOMALIES = i18n.translate('xpack.siem.anomaliesTable.table.anomaliesDescription', {
defaultMessage: 'Anomalies',
});
export const LOADING = i18n.translate('xpack.siem.ml.table.loadingDescription', {
defaultMessage: 'Loading…',
});
export const SCORE = i18n.translate('xpack.siem.ml.table.scoreTitle', {
defaultMessage: 'Score',
});
export const HOST_NAME = i18n.translate('xpack.siem.ml.table.hostNameTitle', {
defaultMessage: 'Host Name',
});
export const INFLUENCED_BY = i18n.translate('xpack.siem.ml.table.influencedByTitle', {
defaultMessage: 'Influenced By',
});
export const ENTITY = i18n.translate('xpack.siem.ml.table.entityTitle', {
defaultMessage: 'Entity',
});
export const DETECTOR = i18n.translate('xpack.siem.ml.table.detectorTitle', {
defaultMessage: 'Detector',
});
export const NETWORK_NAME = i18n.translate('xpack.siem.ml.table.networkNameTitle', {
defaultMessage: 'Network IP',
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface Influencer {
influencer_field_name: string;
influencer_field_values: string[];
}
export interface Source {
job_id: string;
result_type: string;
probability: number;
multi_bucket_impact: number;
record_score: number;
initial_record_score: number;
bucket_span: number;
detector_index: number;
is_interim: boolean;
timestamp: number;
by_field_name: string;
by_field_value: string;
partition_field_name: string;
partition_field_value: string;
function: string;
function_description: string;
typical: number[];
actual: number[];
influencers: Influencer[];
}
export interface Influencer {
influencer_field_name: string;
influencer_field_values: string[];
}
export interface InfluencerInput {
fieldName: string;
fieldValue: string;
}
export interface Anomaly {
detectorIndex: number;
entityName: string;
entityValue: string;
influencers: Array<Record<string, string>>;
jobId: string;
rowId: string;
severity: number;
time: number;
source: Source;
}
export interface Anomalies {
anomalies: Anomaly[];
interval: string;
}
export type NarrowDateRange = (score: Anomaly, interval: string) => void;
export interface AnomaliesByHost {
hostName: string;
anomaly: Anomaly;
}
export type DestinationOrSource = 'source.ip' | 'destination.ip';
export interface AnomaliesByNetwork {
type: DestinationOrSource;
ip: string;
anomaly: Anomaly;
}
export interface AnomaliesTableProps {
startDate: number;
endDate: number;
narrowDateRange: NarrowDateRange;
skip: boolean;
}

View file

@ -2,7 +2,142 @@
exports[`Host Summary Component rendering it renders the default Host Summary 1`] = `
<Component>
<pure(Component)
<Memo()
anomaliesData={
Object {
"anomalies": Array [
Object {
"detectorIndex": 0,
"entityName": "process.name",
"entityValue": "du",
"influencers": Array [
Object {
"host.name": "zeek-iowa",
},
Object {
"process.name": "du",
},
Object {
"user.name": "root",
},
],
"jobId": "job-1",
"rowId": "1561157194802_0",
"severity": 16.193669439507826,
"source": Object {
"actual": Array [
1,
],
"bucket_span": 900,
"by_field_name": "process.name",
"by_field_value": "du",
"detector_index": 0,
"function": "rare",
"function_description": "rare",
"influencers": Array [
Object {
"influencer_field_name": "user.name",
"influencer_field_values": Array [
"root",
],
},
Object {
"influencer_field_name": "process.name",
"influencer_field_values": Array [
"du",
],
},
Object {
"influencer_field_name": "host.name",
"influencer_field_values": Array [
"zeek-iowa",
],
},
],
"initial_record_score": 16.193669439507826,
"is_interim": false,
"job_id": "job-1",
"multi_bucket_impact": 0,
"partition_field_name": "host.name",
"partition_field_value": "zeek-iowa",
"probability": 0.024041164411288146,
"record_score": 16.193669439507826,
"result_type": "record",
"timestamp": 1560664800000,
"typical": Array [
0.024041164411288146,
],
},
"time": 1560664800000,
},
Object {
"detectorIndex": 0,
"entityName": "process.name",
"entityValue": "ls",
"influencers": Array [
Object {
"host.name": "zeek-iowa",
},
Object {
"process.name": "ls",
},
Object {
"user.name": "root",
},
],
"jobId": "job-2",
"rowId": "1561157194802_1",
"severity": 16.193669439507826,
"source": Object {
"actual": Array [
1,
],
"bucket_span": 900,
"by_field_name": "process.name",
"by_field_value": "ls",
"detector_index": 0,
"function": "rare",
"function_description": "rare",
"influencers": Array [
Object {
"influencer_field_name": "user.name",
"influencer_field_values": Array [
"root",
],
},
Object {
"influencer_field_name": "process.name",
"influencer_field_values": Array [
"ls",
],
},
Object {
"influencer_field_name": "host.name",
"influencer_field_values": Array [
"zeek-iowa",
],
},
],
"initial_record_score": 16.193669439507826,
"is_interim": false,
"job_id": "job-2",
"multi_bucket_impact": 0,
"partition_field_name": "host.name",
"partition_field_value": "zeek-iowa",
"probability": 0.024041164411288146,
"record_score": 16.193669439507826,
"result_type": "record",
"timestamp": 1560664800000,
"typical": Array [
0.024041164411288146,
],
},
"time": 1560664800000,
},
],
"interval": "day",
}
}
data={
Object {
"_id": "yneHlmgBjVl2VqDlAjPR",
@ -58,7 +193,11 @@ exports[`Host Summary Component rendering it renders the default Host Summary 1`
},
}
}
endDate={1560837600000}
isLoadingAnomaliesData={false}
loading={false}
narrowDateRange={[MockFunction]}
startDate={1560578400000}
/>
</Component>
`;

View file

@ -11,6 +11,7 @@ import { TestProviders } from '../../../../mock';
import { HostOverview } from './index';
import { mockData } from './mock';
import { mockAnomalies } from '../../../ml/mock';
describe('Host Summary Component', () => {
// this is just a little hack to silence a warning that we'll get until react
@ -38,7 +39,15 @@ describe('Host Summary Component', () => {
test('it renders the default Host Summary', () => {
const wrapper = shallow(
<TestProviders>
<HostOverview loading={false} data={mockData.Hosts.edges[0].node} />
<HostOverview
startDate={new Date('2019-06-15T06:00:00.000Z').valueOf()}
endDate={new Date('2019-06-18T06:00:00.000Z').valueOf()}
loading={false}
data={mockData.Hosts.edges[0].node}
anomaliesData={mockAnomalies}
isLoadingAnomaliesData={false}
narrowDateRange={jest.fn()}
/>
</TestProviders>
);

View file

@ -7,9 +7,9 @@
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 { DescriptionList } from '../../../../../common/utility_types';
import { HostItem } from '../../../../graphql/types';
import { getEmptyTagValue } from '../../../empty_value';
@ -19,20 +19,20 @@ import { DefaultFieldRenderer, hostIdRenderer } from '../../../field_renderers/f
import { LoadingPanel } from '../../../loading';
import { LoadingOverlay, OverviewWrapper } from '../../index';
import { IPDetailsLink } from '../../../links';
import { AnomalyScores } from '../../../ml/score/anomaly_scores';
import { Anomalies, NarrowDateRange } from '../../../ml/types';
interface DescriptionList {
title: string;
description: JSX.Element;
}
interface OwnProps {
interface HostSummaryProps {
data: HostItem;
loading: boolean;
isLoadingAnomaliesData: boolean;
anomaliesData: Anomalies | null;
startDate: number;
endDate: number;
narrowDateRange: NarrowDateRange;
}
type HostSummaryProps = OwnProps;
const DescriptionList = styled(EuiDescriptionList)`
const DescriptionListStyled = styled(EuiDescriptionList)`
${({ theme }) => `
dt {
font-size: ${theme.eui.euiFontSizeXS} !important;
@ -42,112 +42,136 @@ const DescriptionList = styled(EuiDescriptionList)`
const getDescriptionList = (descriptionList: DescriptionList[], key: number) => (
<EuiFlexItem key={key}>
<DescriptionList listItems={descriptionList} />
<DescriptionListStyled listItems={descriptionList} />
</EuiFlexItem>
);
export const HostOverview = pure<HostSummaryProps>(({ data, loading }) => {
const getDefaultRenderer = (fieldName: string, fieldData: HostItem) => (
<DefaultFieldRenderer
rowItems={getOr([], fieldName, fieldData)}
attrName={fieldName}
idPrefix="host-overview"
/>
);
export const HostOverview = React.memo<HostSummaryProps>(
({
data,
loading,
startDate,
endDate,
isLoadingAnomaliesData,
anomaliesData,
narrowDateRange,
}) => {
const getDefaultRenderer = (fieldName: string, fieldData: HostItem) => (
<DefaultFieldRenderer
rowItems={getOr([], fieldName, fieldData)}
attrName={fieldName}
idPrefix="host-overview"
/>
);
const descriptionLists: Readonly<DescriptionList[][]> = [
[
{
title: i18n.HOST_ID,
description: data.host
? hostIdRenderer({ host: data.host, noLink: true })
: getEmptyTagValue(),
},
{
title: i18n.FIRST_SEEN,
description:
data.host != null && data.host.name && data.host.name.length ? (
<FirstLastSeenHost
hostname={data.host.name[0]}
type={FirstLastSeenHostType.FIRST_SEEN}
const descriptionLists: Readonly<DescriptionList[][]> = [
[
{
title: i18n.HOST_ID,
description: data.host
? hostIdRenderer({ host: data.host, noLink: true })
: getEmptyTagValue(),
},
{
title: i18n.FIRST_SEEN,
description:
data.host != null && data.host.name && data.host.name.length ? (
<FirstLastSeenHost
hostname={data.host.name[0]}
type={FirstLastSeenHostType.FIRST_SEEN}
/>
) : (
getEmptyTagValue()
),
},
{
title: i18n.LAST_SEEN,
description:
data.host != null && data.host.name && data.host.name.length ? (
<FirstLastSeenHost
hostname={data.host.name[0]}
type={FirstLastSeenHostType.LAST_SEEN}
/>
) : (
getEmptyTagValue()
),
},
{
title: i18n.MAX_ANOMALY_SCORE_BY_JOB,
description: (
<AnomalyScores
anomalies={anomaliesData}
startDate={startDate}
endDate={endDate}
isLoading={isLoadingAnomaliesData}
narrowDateRange={narrowDateRange}
/>
) : (
getEmptyTagValue()
),
},
{
title: i18n.LAST_SEEN,
description:
data.host != null && data.host.name && data.host.name.length ? (
<FirstLastSeenHost
hostname={data.host.name[0]}
type={FirstLastSeenHostType.LAST_SEEN}
},
],
[
{
title: i18n.IP_ADDRESSES,
description: (
<DefaultFieldRenderer
rowItems={getOr([], 'host.ip', data)}
attrName={'host.ip'}
idPrefix="host-overview"
render={ip => (ip != null ? <IPDetailsLink ip={ip} /> : getEmptyTagValue())}
/>
) : (
getEmptyTagValue()
),
},
],
[
{
title: i18n.IP_ADDRESSES,
description: (
<DefaultFieldRenderer
rowItems={getOr([], 'host.ip', data)}
attrName={'host.ip'}
idPrefix="host-overview"
render={ip => (ip != null ? <IPDetailsLink ip={ip} /> : getEmptyTagValue())}
/>
),
},
{
title: i18n.MAC_ADDRESSES,
description: getDefaultRenderer('host.mac', data),
},
{ title: i18n.PLATFORM, description: getDefaultRenderer('host.os.platform', data) },
],
[
{ title: i18n.OS, description: getDefaultRenderer('host.os.name', data) },
{ title: i18n.FAMILY, description: getDefaultRenderer('host.os.family', data) },
{ title: i18n.VERSION, description: getDefaultRenderer('host.os.version', data) },
{ title: i18n.ARCHITECTURE, description: getDefaultRenderer('host.architecture', data) },
],
[
{
title: i18n.CLOUD_PROVIDER,
description: getDefaultRenderer('cloud.provider', data),
},
{
title: i18n.REGION,
description: getDefaultRenderer('cloud.region', data),
},
{
title: i18n.INSTANCE_ID,
description: getDefaultRenderer('cloud.instance.id', data),
},
{
title: i18n.MACHINE_TYPE,
description: getDefaultRenderer('cloud.machine.type', data),
},
],
];
},
{
title: i18n.MAC_ADDRESSES,
description: getDefaultRenderer('host.mac', data),
},
{ title: i18n.PLATFORM, description: getDefaultRenderer('host.os.platform', data) },
],
[
{ title: i18n.OS, description: getDefaultRenderer('host.os.name', data) },
{ title: i18n.FAMILY, description: getDefaultRenderer('host.os.family', data) },
{ title: i18n.VERSION, description: getDefaultRenderer('host.os.version', data) },
{ title: i18n.ARCHITECTURE, description: getDefaultRenderer('host.architecture', data) },
],
[
{
title: i18n.CLOUD_PROVIDER,
description: getDefaultRenderer('cloud.provider', data),
},
{
title: i18n.REGION,
description: getDefaultRenderer('cloud.region', data),
},
{
title: i18n.INSTANCE_ID,
description: getDefaultRenderer('cloud.instance.id', data),
},
{
title: i18n.MACHINE_TYPE,
description: getDefaultRenderer('cloud.machine.type', data),
},
],
];
return (
<OverviewWrapper>
{loading && (
<>
<LoadingOverlay />
<LoadingPanel
height="100%"
width="100%"
text=""
position="absolute"
zIndex={3}
data-test-subj="LoadingPanelLoadMoreTable"
/>
</>
)}
{descriptionLists.map((descriptionList, index) => getDescriptionList(descriptionList, index))}
</OverviewWrapper>
);
});
return (
<OverviewWrapper>
{loading && (
<>
<LoadingOverlay />
<LoadingPanel
height="100%"
width="100%"
text=""
position="absolute"
zIndex={3}
data-test-subj="LoadingPanelLoadMoreTable"
/>
</>
)}
{descriptionLists.map((descriptionList, index) =>
getDescriptionList(descriptionList, index)
)}
</OverviewWrapper>
);
}
);

View file

@ -18,6 +18,13 @@ export const LAST_SEEN = i18n.translate('xpack.siem.host.details.lastSeenTitle',
defaultMessage: 'Last Seen',
});
export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate(
'xpack.siem.host.details.overview.maxAnomalyScoreByJobTitle',
{
defaultMessage: 'Max Anomaly Score By Job',
}
);
export const IP_ADDRESSES = i18n.translate('xpack.siem.host.details.overview.ipAddressesTitle', {
defaultMessage: 'IP Addresses',
});

View file

@ -13,9 +13,148 @@ exports[`IP Overview Component rendering it renders the default IP Overview 1`]
}
>
<pure(Component)
anomaliesData={
Object {
"anomalies": Array [
Object {
"detectorIndex": 0,
"entityName": "process.name",
"entityValue": "du",
"influencers": Array [
Object {
"host.name": "zeek-iowa",
},
Object {
"process.name": "du",
},
Object {
"user.name": "root",
},
],
"jobId": "job-1",
"rowId": "1561157194802_0",
"severity": 16.193669439507826,
"source": Object {
"actual": Array [
1,
],
"bucket_span": 900,
"by_field_name": "process.name",
"by_field_value": "du",
"detector_index": 0,
"function": "rare",
"function_description": "rare",
"influencers": Array [
Object {
"influencer_field_name": "user.name",
"influencer_field_values": Array [
"root",
],
},
Object {
"influencer_field_name": "process.name",
"influencer_field_values": Array [
"du",
],
},
Object {
"influencer_field_name": "host.name",
"influencer_field_values": Array [
"zeek-iowa",
],
},
],
"initial_record_score": 16.193669439507826,
"is_interim": false,
"job_id": "job-1",
"multi_bucket_impact": 0,
"partition_field_name": "host.name",
"partition_field_value": "zeek-iowa",
"probability": 0.024041164411288146,
"record_score": 16.193669439507826,
"result_type": "record",
"timestamp": 1560664800000,
"typical": Array [
0.024041164411288146,
],
},
"time": 1560664800000,
},
Object {
"detectorIndex": 0,
"entityName": "process.name",
"entityValue": "ls",
"influencers": Array [
Object {
"host.name": "zeek-iowa",
},
Object {
"process.name": "ls",
},
Object {
"user.name": "root",
},
],
"jobId": "job-2",
"rowId": "1561157194802_1",
"severity": 16.193669439507826,
"source": Object {
"actual": Array [
1,
],
"bucket_span": 900,
"by_field_name": "process.name",
"by_field_value": "ls",
"detector_index": 0,
"function": "rare",
"function_description": "rare",
"influencers": Array [
Object {
"influencer_field_name": "user.name",
"influencer_field_values": Array [
"root",
],
},
Object {
"influencer_field_name": "process.name",
"influencer_field_values": Array [
"ls",
],
},
Object {
"influencer_field_name": "host.name",
"influencer_field_values": Array [
"zeek-iowa",
],
},
],
"initial_record_score": 16.193669439507826,
"is_interim": false,
"job_id": "job-2",
"multi_bucket_impact": 0,
"partition_field_name": "host.name",
"partition_field_value": "zeek-iowa",
"probability": 0.024041164411288146,
"record_score": 16.193669439507826,
"result_type": "record",
"timestamp": 1560664800000,
"typical": Array [
0.024041164411288146,
],
},
"time": 1560664800000,
},
],
"interval": "day",
}
}
endDate={1560837600000}
flowTarget="source"
ip="10.10.10.10"
isLoadingAnomaliesData={false}
loading={false}
narrowDateRange={[MockFunction]}
startDate={1560578400000}
type="details"
updateFlowTargetAction={[MockFunction]}
/>

View file

@ -15,6 +15,8 @@ import { createStore, networkModel, State } from '../../../../store';
import { IpOverview } from './index';
import { mockData } from './mock';
import { mockAnomalies } from '../../../ml/mock';
import { NarrowDateRange } from '../../../ml/types';
describe('IP Overview Component', () => {
const state: State = mockGlobalState;
@ -35,6 +37,11 @@ describe('IP Overview Component', () => {
updateFlowTargetAction: (jest.fn() as unknown) as ActionCreator<{
flowTarget: FlowTarget;
}>,
startDate: new Date('2019-06-15T06:00:00.000Z').valueOf(),
endDate: new Date('2019-06-18T06:00:00.000Z').valueOf(),
anomaliesData: mockAnomalies,
isLoadingAnomaliesData: false,
narrowDateRange: (jest.fn() as unknown) as NarrowDateRange,
};
test('it renders the default IP Overview', () => {

View file

@ -9,6 +9,7 @@ import React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { DescriptionList } from '../../../../../common/utility_types';
import { FlowTarget, IpOverviewData, Overview } from '../../../../graphql/types';
import { networkModel } from '../../../../store';
import { getEmptyTagValue } from '../../../empty_value';
@ -25,23 +26,25 @@ import {
import * as i18n from './translations';
import { LoadingOverlay, OverviewWrapper } from '../../index';
import { LoadingPanel } from '../../../loading';
interface DescriptionList {
title: string;
description: JSX.Element;
}
import { Anomalies, NarrowDateRange } from '../../../ml/types';
import { AnomalyScores } from '../../../ml/score/anomaly_scores';
interface OwnProps {
data: IpOverviewData;
flowTarget: FlowTarget;
ip: string;
loading: boolean;
isLoadingAnomaliesData: boolean;
anomaliesData: Anomalies | null;
startDate: number;
endDate: number;
type: networkModel.NetworkType;
narrowDateRange: NarrowDateRange;
}
export type IpOverviewProps = OwnProps;
const DescriptionList = styled(EuiDescriptionList)`
const DescriptionListStyled = styled(EuiDescriptionList)`
${({ theme }) => `
dt {
font-size: ${theme.eui.euiFontSizeXS} !important;
@ -52,66 +55,92 @@ const DescriptionList = styled(EuiDescriptionList)`
const getDescriptionList = (descriptionList: DescriptionList[], key: number) => {
return (
<EuiFlexItem key={key}>
<DescriptionList listItems={descriptionList} />
<DescriptionListStyled listItems={descriptionList} />
</EuiFlexItem>
);
};
export const IpOverview = pure<IpOverviewProps>(({ ip, data, loading, flowTarget }) => {
const typeData: Overview = data[flowTarget]!;
const descriptionLists: Readonly<DescriptionList[][]> = [
[
{
title: i18n.LOCATION,
description: locationRenderer(
[`${flowTarget}.geo.city_name`, `${flowTarget}.geo.region_name`],
data
),
},
{
title: i18n.AUTONOMOUS_SYSTEM,
description: typeData
? autonomousSystemRenderer(typeData.autonomousSystem, flowTarget)
: getEmptyTagValue(),
},
],
[
{ title: i18n.FIRST_SEEN, description: dateRenderer('firstSeen', typeData) },
{ title: i18n.LAST_SEEN, description: dateRenderer('lastSeen', typeData) },
],
[
{
title: i18n.HOST_ID,
description: typeData
? hostIdRenderer({ host: data.host, ipFilter: ip })
: getEmptyTagValue(),
},
{
title: i18n.HOST_NAME,
description: typeData ? hostNameRenderer(data.host, ip) : getEmptyTagValue(),
},
],
[
{ title: i18n.WHOIS, description: whoisRenderer(ip) },
{ title: i18n.REPUTATION, description: reputationRenderer(ip) },
],
];
return (
<OverviewWrapper>
{loading && (
<>
<LoadingOverlay />
<LoadingPanel
height="100%"
width="100%"
text=""
position="absolute"
zIndex={3}
data-test-subj="LoadingPanelLoadMoreTable"
/>
</>
)}
{descriptionLists.map((descriptionList, index) => getDescriptionList(descriptionList, index))}
</OverviewWrapper>
);
});
export const IpOverview = pure<IpOverviewProps>(
({
ip,
data,
loading,
flowTarget,
startDate,
endDate,
isLoadingAnomaliesData,
anomaliesData,
narrowDateRange,
}) => {
const typeData: Overview = data[flowTarget]!;
const descriptionLists: Readonly<DescriptionList[][]> = [
[
{
title: i18n.LOCATION,
description: locationRenderer(
[`${flowTarget}.geo.city_name`, `${flowTarget}.geo.region_name`],
data
),
},
{
title: i18n.AUTONOMOUS_SYSTEM,
description: typeData
? autonomousSystemRenderer(typeData.autonomousSystem, flowTarget)
: getEmptyTagValue(),
},
{
title: i18n.MAX_ANOMALY_SCORE_BY_JOB,
description: (
<AnomalyScores
anomalies={anomaliesData}
startDate={startDate}
endDate={endDate}
isLoading={isLoadingAnomaliesData}
narrowDateRange={narrowDateRange}
/>
),
},
],
[
{ title: i18n.FIRST_SEEN, description: dateRenderer('firstSeen', typeData) },
{ title: i18n.LAST_SEEN, description: dateRenderer('lastSeen', typeData) },
],
[
{
title: i18n.HOST_ID,
description: typeData
? hostIdRenderer({ host: data.host, ipFilter: ip })
: getEmptyTagValue(),
},
{
title: i18n.HOST_NAME,
description: typeData ? hostNameRenderer(data.host, ip) : getEmptyTagValue(),
},
],
[
{ title: i18n.WHOIS, description: whoisRenderer(ip) },
{ title: i18n.REPUTATION, description: reputationRenderer(ip) },
],
];
return (
<OverviewWrapper>
{loading && (
<>
<LoadingOverlay />
<LoadingPanel
height="100%"
width="100%"
text=""
position="absolute"
zIndex={3}
data-test-subj="LoadingPanelLoadMoreTable"
/>
</>
)}
{descriptionLists.map((descriptionList, index) =>
getDescriptionList(descriptionList, index)
)}
</OverviewWrapper>
);
}
);

View file

@ -21,6 +21,13 @@ export const AUTONOMOUS_SYSTEM = i18n.translate(
}
);
export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate(
'xpack.siem.network.ipDetails.ipOverview.maxAnomalyScoreByJobTitle',
{
defaultMessage: 'Max Anomaly Score By Job',
}
);
export const FIRST_SEEN = i18n.translate('xpack.siem.network.ipDetails.ipOverview.firstSeenTitle', {
defaultMessage: 'First Seen',
});

View file

@ -13,6 +13,7 @@ import { pure } from 'recompose';
import { Breadcrumb } from 'ui/chrome';
import { StaticIndexPattern } from 'ui/index_patterns';
import { ActionCreator } from 'typescript-fsa';
import { ESTermQuery } from '../../../common/typed_json';
import { FiltersGlobal } from '../../components/filters_global';
import { HeaderPage } from '../../components/header_page';
@ -36,6 +37,11 @@ import { hostsModel, hostsSelectors, State } from '../../store';
import { HostsEmptyPage } from './hosts_empty_page';
import { HostsKql } from './kql';
import * as i18n from './translations';
import { AnomalyTableProvider } from '../../components/ml/anomaly/anomaly_table_provider';
import { hostToInfluencers } from '../../components/ml/influencers/host_to_influencers';
import { setAbsoluteRangeDatePicker as dispatchAbsoluteRangeDatePicker } from '../../store/inputs/actions';
import { InputsModelId } from '../../store/inputs/constants';
import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime';
import { KpiHostDetailsQuery } from '../../containers/kpi_host_details';
const type = hostsModel.HostsType.details;
@ -48,6 +54,11 @@ const KpiHostDetailsManage = manageQuery(KpiHostsComponent);
interface HostDetailsComponentReduxProps {
filterQueryExpression: string;
setAbsoluteRangeDatePicker: ActionCreator<{
id: InputsModelId;
from: number;
to: number;
}>;
}
type HostDetailsComponentProps = HostDetailsComponentReduxProps & HostComponentProps;
@ -58,6 +69,7 @@ const HostDetailsComponent = pure<HostDetailsComponentProps>(
params: { hostName },
},
filterQueryExpression,
setAbsoluteRangeDatePicker,
}) => (
<WithSource sourceId="default">
{({ indicesExist, indexPattern }) =>
@ -87,13 +99,33 @@ const HostDetailsComponent = pure<HostDetailsComponentProps>(
endDate={to}
>
{({ hostOverview, loading, id, refetch }) => (
<HostOverviewManage
id={id}
refetch={refetch}
setQuery={setQuery}
data={hostOverview}
loading={loading}
/>
<AnomalyTableProvider
influencers={hostToInfluencers(hostOverview)}
startDate={from}
endDate={to}
>
{({ isLoadingAnomaliesData, anomaliesData }) => (
<HostOverviewManage
id={id}
refetch={refetch}
setQuery={setQuery}
data={hostOverview}
anomaliesData={anomaliesData}
isLoadingAnomaliesData={isLoadingAnomaliesData}
loading={loading}
startDate={from}
endDate={to}
narrowDateRange={(score, interval) => {
const fromTo = scoreIntervalToDateTime(score, interval);
setAbsoluteRangeDatePicker({
id: 'global',
from: fromTo.from,
to: fromTo.to,
});
}}
/>
)}
</AnomalyTableProvider>
)}
</HostOverviewByNameQuery>
@ -236,7 +268,12 @@ const makeMapStateToProps = () => {
});
};
export const HostDetails = connect(makeMapStateToProps)(HostDetailsComponent);
export const HostDetails = connect(
makeMapStateToProps,
{
setAbsoluteRangeDatePicker: dispatchAbsoluteRangeDatePicker,
}
)(HostDetailsComponent);
export const getBreadcrumbs = (hostId: string): Breadcrumb[] => [
{

View file

@ -11,6 +11,7 @@ import { connect } from 'react-redux';
import { StickyContainer } from 'react-sticky';
import { pure } from 'recompose';
import { ActionCreator } from 'typescript-fsa';
import { FiltersGlobal } from '../../components/filters_global';
import { HeaderPage } from '../../components/header_page';
import { LastEventTime } from '../../components/last_event_time';
@ -36,19 +37,29 @@ import { hostsModel, hostsSelectors, State } from '../../store';
import { HostsEmptyPage } from './hosts_empty_page';
import { HostsKql } from './kql';
import * as i18n from './translations';
import { AnomaliesHostTable } from '../../components/ml/tables/anomalies_host_table';
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions';
import { InputsModelId } from '../../store/inputs/constants';
import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime';
const AuthenticationTableManage = manageQuery(AuthenticationTable);
const HostsTableManage = manageQuery(HostsTable);
const EventsTableManage = manageQuery(EventsTable);
const UncommonProcessTableManage = manageQuery(UncommonProcessTable);
const KpiHostsComponentManage = manageQuery(KpiHostsComponent);
interface HostsComponentReduxProps {
filterQuery: string;
setAbsoluteRangeDatePicker: ActionCreator<{
id: InputsModelId;
from: number;
to: number;
}>;
}
type HostsComponentProps = HostsComponentReduxProps;
const HostsComponent = pure<HostsComponentProps>(({ filterQuery }) => (
const HostsComponent = pure<HostsComponentProps>(({ filterQuery, setAbsoluteRangeDatePicker }) => (
<WithSource sourceId="default">
{({ indicesExist, indexPattern }) =>
indicesExistOrDataTemporarilyUnavailable(indicesExist) ? (
@ -182,6 +193,22 @@ const HostsComponent = pure<HostsComponentProps>(({ filterQuery }) => (
<EuiSpacer />
<AnomaliesHostTable
startDate={from}
endDate={to}
skip={isInitializing}
narrowDateRange={(score, interval) => {
const fromTo = scoreIntervalToDateTime(score, interval);
setAbsoluteRangeDatePicker({
id: 'global',
from: fromTo.from,
to: fromTo.to,
});
}}
/>
<EuiSpacer />
<EventsQuery
endDate={to}
filterQuery={filterQuery}
@ -230,4 +257,9 @@ const makeMapStateToProps = () => {
return mapStateToProps;
};
export const Hosts = connect(makeMapStateToProps)(HostsComponent);
export const Hosts = connect(
makeMapStateToProps,
{
setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker,
}
)(HostsComponent);

View file

@ -42,5 +42,6 @@ exports[`Ip Details it matches the snapshot 1`] = `
"url": "",
}
}
setAbsoluteRangeDatePicker={[MockFunction]}
/>
`;

View file

@ -18,10 +18,14 @@ import { FlowTarget } from '../../graphql/types';
import { createStore, State } from '../../store';
import { cloneDeep } from 'lodash/fp';
import { mocksSource } from '../../containers/source/mock';
import { InputsModelId } from '../../store/inputs/constants';
import { ActionCreator } from 'typescript-fsa';
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
type GlobalWithFetch = NodeJS.Global & { fetch: jest.Mock };
let localSource: Array<{
request: {};
result: {
@ -65,6 +69,11 @@ const getMockProps = (ip: string) => ({
hash: '',
},
match: { params: { ip }, isExact: true, path: '', url: '' },
setAbsoluteRangeDatePicker: (jest.fn() as unknown) as ActionCreator<{
id: InputsModelId;
from: number;
to: number;
}>,
});
jest.mock('ui/documentation_links', () => ({
@ -72,6 +81,7 @@ jest.mock('ui/documentation_links', () => ({
siem: 'http://www.example.com',
},
}));
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769
/* eslint-disable no-console */
const originalError = console.error;
@ -79,10 +89,19 @@ const originalError = console.error;
describe('Ip Details', () => {
beforeAll(() => {
console.error = jest.fn();
(global as GlobalWithFetch).fetch = jest.fn().mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => {
return null;
},
})
);
});
afterAll(() => {
console.error = originalError;
delete (global as GlobalWithFetch).fetch;
});
const state: State = mockGlobalState;

View file

@ -12,6 +12,7 @@ import { StickyContainer } from 'react-sticky';
import { pure } from 'recompose';
import { Breadcrumb } from 'ui/chrome';
import { ActionCreator } from 'typescript-fsa';
import { FiltersGlobal } from '../../components/filters_global';
import { HeaderPage } from '../../components/header_page';
import { LastEventTime } from '../../components/last_event_time';
@ -32,10 +33,15 @@ import { UsersQuery } from '../../containers/users';
import { FlowTarget, LastEventIndexKey } from '../../graphql/types';
import { decodeIpv6 } from '../../lib/helpers';
import { networkModel, networkSelectors, State } from '../../store';
import { setAbsoluteRangeDatePicker as dispatchAbsoluteRangeDatePicker } from '../../store/inputs/actions';
import { NetworkKql } from './kql';
import { NetworkEmptyPage } from './network_empty_page';
import * as i18n from './translations';
import { AnomalyTableProvider } from '../../components/ml/anomaly/anomaly_table_provider';
import { networkToInfluencers } from '../../components/ml/influencers/network_to_influencers';
import { InputsModelId } from '../../store/inputs/constants';
import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime';
const DomainsTableManage = manageQuery(DomainsTable);
const TlsTableManage = manageQuery(TlsTable);
@ -44,6 +50,11 @@ const UsersTableManage = manageQuery(UsersTable);
interface IPDetailsComponentReduxProps {
filterQuery: string;
flowTarget: FlowTarget;
setAbsoluteRangeDatePicker: ActionCreator<{
id: InputsModelId;
from: number;
to: number;
}>;
}
type IPDetailsComponentProps = IPDetailsComponentReduxProps & NetworkComponentProps;
@ -55,6 +66,7 @@ export const IPDetailsComponent = pure<IPDetailsComponentProps>(
},
filterQuery,
flowTarget,
setAbsoluteRangeDatePicker,
}) => (
<WithSource sourceId="default" data-test-subj="ip-details-page">
{({ indicesExist, indexPattern }) =>
@ -87,13 +99,33 @@ export const IPDetailsComponent = pure<IPDetailsComponentProps>(
ip={decodeIpv6(ip)}
>
{({ ipOverviewData, loading }) => (
<IpOverview
ip={decodeIpv6(ip)}
data={ipOverviewData}
loading={loading}
type={networkModel.NetworkType.details}
flowTarget={flowTarget}
/>
<AnomalyTableProvider
influencers={networkToInfluencers(ip)}
startDate={from}
endDate={to}
>
{({ isLoadingAnomaliesData, anomaliesData }) => (
<IpOverview
ip={decodeIpv6(ip)}
data={ipOverviewData}
anomaliesData={anomaliesData}
loading={loading}
isLoadingAnomaliesData={isLoadingAnomaliesData}
type={networkModel.NetworkType.details}
flowTarget={flowTarget}
startDate={from}
endDate={to}
narrowDateRange={(score, interval) => {
const fromTo = scoreIntervalToDateTime(score, interval);
setAbsoluteRangeDatePicker({
id: 'global',
from: fromTo.from,
to: fromTo.to,
});
}}
/>
)}
</AnomalyTableProvider>
)}
</IpOverviewQuery>
@ -211,7 +243,12 @@ const makeMapStateToProps = () => {
});
};
export const IPDetails = connect(makeMapStateToProps)(IPDetailsComponent);
export const IPDetails = connect(
makeMapStateToProps,
{
setAbsoluteRangeDatePicker: dispatchAbsoluteRangeDatePicker,
}
)(IPDetailsComponent);
export const getBreadcrumbs = (ip: string): Breadcrumb[] => [
{

View file

@ -11,6 +11,7 @@ import { connect } from 'react-redux';
import { StickyContainer } from 'react-sticky';
import { pure } from 'recompose';
import { ActionCreator } from 'typescript-fsa';
import { FiltersGlobal } from '../../components/filters_global';
import { HeaderPage } from '../../components/header_page';
import { LastEventTime } from '../../components/last_event_time';
@ -29,128 +30,155 @@ import { networkModel, networkSelectors, State } from '../../store';
import { NetworkKql } from './kql';
import { NetworkEmptyPage } from './network_empty_page';
import * as i18n from './translations';
import { AnomaliesNetworkTable } from '../../components/ml/tables/anomalies_network_table';
import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime';
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions';
import { InputsModelId } from '../../store/inputs/constants';
const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable);
const NetworkDnsTableManage = manageQuery(NetworkDnsTable);
const KpiNetworkComponentManage = manageQuery(KpiNetworkComponent);
interface NetworkComponentReduxProps {
filterQuery: string;
setAbsoluteRangeDatePicker: ActionCreator<{
id: InputsModelId;
from: number;
to: number;
}>;
}
type NetworkComponentProps = NetworkComponentReduxProps;
const NetworkComponent = pure<NetworkComponentProps>(({ filterQuery }) => (
<WithSource sourceId="default">
{({ indicesExist, indexPattern }) =>
indicesExistOrDataTemporarilyUnavailable(indicesExist) ? (
<StickyContainer>
<FiltersGlobal>
<NetworkKql indexPattern={indexPattern} type={networkModel.NetworkType.page} />
</FiltersGlobal>
const NetworkComponent = pure<NetworkComponentProps>(
({ filterQuery, setAbsoluteRangeDatePicker }) => (
<WithSource sourceId="default">
{({ indicesExist, indexPattern }) =>
indicesExistOrDataTemporarilyUnavailable(indicesExist) ? (
<StickyContainer>
<FiltersGlobal>
<NetworkKql indexPattern={indexPattern} type={networkModel.NetworkType.page} />
</FiltersGlobal>
<HeaderPage
subtitle={<LastEventTime indexKey={LastEventIndexKey.network} />}
title={i18n.PAGE_TITLE}
/>
<HeaderPage
subtitle={<LastEventTime indexKey={LastEventIndexKey.network} />}
title={i18n.PAGE_TITLE}
/>
<GlobalTime>
{({ to, from, setQuery }) => (
<UseUrlState indexPattern={indexPattern}>
{({ isInitializing }) => (
<>
<KpiNetworkQuery
endDate={to}
filterQuery={filterQuery}
skip={isInitializing}
sourceId="default"
startDate={from}
>
{({ kpiNetwork, loading, id, refetch }) => (
<KpiNetworkComponentManage
id={id}
setQuery={setQuery}
refetch={refetch}
data={kpiNetwork}
loading={loading}
/>
)}
</KpiNetworkQuery>
<GlobalTime>
{({ to, from, setQuery }) => (
<UseUrlState indexPattern={indexPattern}>
{({ isInitializing }) => (
<>
<KpiNetworkQuery
endDate={to}
filterQuery={filterQuery}
skip={isInitializing}
sourceId="default"
startDate={from}
>
{({ kpiNetwork, loading, id, refetch }) => (
<KpiNetworkComponentManage
id={id}
setQuery={setQuery}
refetch={refetch}
data={kpiNetwork}
loading={loading}
/>
)}
</KpiNetworkQuery>
<EuiSpacer />
<EuiSpacer />
<NetworkTopNFlowQuery
endDate={to}
filterQuery={filterQuery}
skip={isInitializing}
sourceId="default"
startDate={from}
type={networkModel.NetworkType.page}
>
{({
totalCount,
loading,
networkTopNFlow,
pageInfo,
loadMore,
id,
refetch,
}) => (
<NetworkTopNFlowTableManage
data={networkTopNFlow}
indexPattern={indexPattern}
id={id}
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
loading={loading}
loadMore={loadMore}
nextCursor={getOr(null, 'endCursor.value', pageInfo)}
refetch={refetch}
setQuery={setQuery}
totalCount={totalCount}
type={networkModel.NetworkType.page}
/>
)}
</NetworkTopNFlowQuery>
<NetworkTopNFlowQuery
endDate={to}
filterQuery={filterQuery}
skip={isInitializing}
sourceId="default"
startDate={from}
type={networkModel.NetworkType.page}
>
{({
totalCount,
loading,
networkTopNFlow,
pageInfo,
loadMore,
id,
refetch,
}) => (
<NetworkTopNFlowTableManage
data={networkTopNFlow}
indexPattern={indexPattern}
id={id}
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
loading={loading}
loadMore={loadMore}
nextCursor={getOr(null, 'endCursor.value', pageInfo)}
refetch={refetch}
setQuery={setQuery}
totalCount={totalCount}
type={networkModel.NetworkType.page}
/>
)}
</NetworkTopNFlowQuery>
<EuiSpacer />
<EuiSpacer />
<NetworkDnsQuery
endDate={to}
filterQuery={filterQuery}
skip={isInitializing}
sourceId="default"
startDate={from}
type={networkModel.NetworkType.page}
>
{({ totalCount, loading, networkDns, pageInfo, loadMore, id, refetch }) => (
<NetworkDnsTableManage
data={networkDns}
id={id}
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
loading={loading}
loadMore={loadMore}
nextCursor={getOr(null, 'endCursor.value', pageInfo)}
refetch={refetch}
setQuery={setQuery}
totalCount={totalCount}
type={networkModel.NetworkType.page}
/>
)}
</NetworkDnsQuery>
</>
)}
</UseUrlState>
)}
</GlobalTime>
</StickyContainer>
) : (
<>
<HeaderPage title={i18n.PAGE_TITLE} />
<NetworkDnsQuery
endDate={to}
filterQuery={filterQuery}
skip={isInitializing}
sourceId="default"
startDate={from}
type={networkModel.NetworkType.page}
>
{({ totalCount, loading, networkDns, pageInfo, loadMore, id, refetch }) => (
<NetworkDnsTableManage
data={networkDns}
id={id}
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
loading={loading}
loadMore={loadMore}
nextCursor={getOr(null, 'endCursor.value', pageInfo)}
refetch={refetch}
setQuery={setQuery}
totalCount={totalCount}
type={networkModel.NetworkType.page}
/>
)}
</NetworkDnsQuery>
<NetworkEmptyPage />
</>
)
}
</WithSource>
));
<EuiSpacer />
<AnomaliesNetworkTable
startDate={from}
endDate={to}
skip={isInitializing}
narrowDateRange={(score, interval) => {
const fromTo = scoreIntervalToDateTime(score, interval);
setAbsoluteRangeDatePicker({
id: 'global',
from: fromTo.from,
to: fromTo.to,
});
}}
/>
</>
)}
</UseUrlState>
)}
</GlobalTime>
</StickyContainer>
) : (
<>
<HeaderPage title={i18n.PAGE_TITLE} />
<NetworkEmptyPage />
</>
)
}
</WithSource>
)
);
const makeMapStateToProps = () => {
const getNetworkFilterQueryAsJson = networkSelectors.networkFilterQueryAsJson();
@ -160,4 +188,9 @@ const makeMapStateToProps = () => {
return mapStateToProps;
};
export const Network = connect(makeMapStateToProps)(NetworkComponent);
export const Network = connect(
makeMapStateToProps,
{
setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker,
}
)(NetworkComponent);