[SIEM] Adds Signals Histogram (#53742)

## Summary

Detection Engine Meta Issue: #50405

This PR adds the `Signals Histogram` component for use on the main `Detection Engine` page, `Rule Details` page, and the newly designed `Overview` page.

Out of the box configuration includes an `EuiSelect` for stacking by the following:
* Risk Scores
* Severities
* Event Actions
* Event Categories
* Host Names
* Rule Types
* Rules
* Users
* Destination IPs
* Source IPs

Additional configuration properties are available to configure the component as needed depending on where it will be displayed (e.g. no `Stack By` option on `Overview`, filter to specific `rule_id` on `Rule Details`, etc):

``` ts
interface SignalsHistogramPanelProps {
  defaultStackByOption?: SignalsHistogramOption;
  filters?: esFilters.Filter[];
  from: number;
  query?: Query;
  legendPosition?: 'left' | 'right' | 'bottom' | 'top';
  loadingInitial?: boolean;
  showLinkToSignals?: boolean;
  showTotalSignalsCount?: boolean;
  stackByOptions?: SignalsHistogramOption[];
  title?: string;
  to: number;
  updateDateRange: (min: number, max: number) => void;
}
```
##### Light Theme:
![de_hist_light](https://user-images.githubusercontent.com/2946766/71299977-41685800-234e-11ea-93bd-05a0c4cb6ee1.gif)

##### Dark Theme:
![de_histogram_dark](https://user-images.githubusercontent.com/2946766/71299980-45947580-234e-11ea-9d26-380bae5c4aa6.gif)


##### Overview:

Example props for overview impl:

``` jsx
<SignalsHistogramPanel
  filters={filters}
  from={from}
  loadingInitial={loading}
  query={query}
  showTotalSignalsCount={true}
  showLinkToSignals={true}
  defaultStackByOption={{
    text: 'Signals count by MITRE ATT&CK category',
    value: 'signal.rule.threats',
  }}
  legendPosition={'right'}
  to={to}
  title="Signals count by MITRE ATT&CK category"
  updateDateRange={updateDateRangeCallback}
/>
```
![image](https://user-images.githubusercontent.com/2946766/72030438-2fd7e900-3246-11ea-8404-40905ca5f85c.png)


Note @andrew-goldstein @angorayc @MichaelMarcialis -- looks like the MITRE ATT&CK Tactics are stored as a nested object in `signal.rule.threat`, so we may have to do some finangling to get it to show on the histogram. 

e.g. format:

``` json
{
  "framework": "MITRE ATT&CK",
  "tactic": {
    "id": "TA0010",
    "reference": "https://attack.mitre.org/tactics/TA0010",
    "name": "Exfiltration"
  },
  "techniques": [
    {
      "id": "T1002",
      "name": "Data Compressed",
      "reference": "https://attack.mitre.org/techniques/T1002"
    }
  ]
}
```




### Checklist

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

- [ ] 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
  * Will work with @benskelker on any specific documentation
- [ ] [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

- [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
- [ ] ~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:
Garrett Spong 2020-01-09 17:52:57 -07:00 committed by GitHub
parent 68883c6333
commit 482faae799
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 832 additions and 359 deletions

View file

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HistogramSignals it renders 1`] = `<HistogramSignals />`;

View file

@ -1,23 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { TestProviders } from '../../../../mock';
import { HistogramSignals } from './index';
describe('HistogramSignals', () => {
test('it renders', () => {
const wrapper = shallow(
<TestProviders>
<HistogramSignals />
</TestProviders>
);
expect(wrapper.find('HistogramSignals')).toMatchSnapshot();
});
});

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
Axis,
Chart,
HistogramBarSeries,
Settings,
niceTimeFormatByDay,
timeFormatter,
} from '@elastic/charts';
import React from 'react';
import { npStart } from 'ui/new_platform';
export const HistogramSignals = React.memo(() => {
const sampleChartData = [
{ x: 1571090784000, y: 2, a: 'a' },
{ x: 1571090784000, y: 2, b: 'b' },
{ x: 1571093484000, y: 7, a: 'a' },
{ x: 1571096184000, y: 3, a: 'a' },
{ x: 1571098884000, y: 2, a: 'a' },
{ x: 1571101584000, y: 7, a: 'a' },
{ x: 1571104284000, y: 3, a: 'a' },
{ x: 1571106984000, y: 2, a: 'a' },
{ x: 1571109684000, y: 7, a: 'a' },
{ x: 1571112384000, y: 3, a: 'a' },
{ x: 1571115084000, y: 2, a: 'a' },
{ x: 1571117784000, y: 7, a: 'a' },
{ x: 1571120484000, y: 3, a: 'a' },
{ x: 1571123184000, y: 2, a: 'a' },
{ x: 1571125884000, y: 7, a: 'a' },
{ x: 1571128584000, y: 3, a: 'a' },
{ x: 1571131284000, y: 2, a: 'a' },
{ x: 1571133984000, y: 7, a: 'a' },
{ x: 1571136684000, y: 3, a: 'a' },
{ x: 1571139384000, y: 2, a: 'a' },
{ x: 1571142084000, y: 7, a: 'a' },
{ x: 1571144784000, y: 3, a: 'a' },
{ x: 1571147484000, y: 2, a: 'a' },
{ x: 1571150184000, y: 7, a: 'a' },
{ x: 1571152884000, y: 3, a: 'a' },
{ x: 1571155584000, y: 2, a: 'a' },
{ x: 1571158284000, y: 7, a: 'a' },
{ x: 1571160984000, y: 3, a: 'a' },
{ x: 1571163684000, y: 2, a: 'a' },
{ x: 1571166384000, y: 7, a: 'a' },
{ x: 1571169084000, y: 3, a: 'a' },
{ x: 1571171784000, y: 2, a: 'a' },
{ x: 1571174484000, y: 7, a: 'a' },
];
return (
<Chart size={['100%', 259]}>
<Settings
legendPosition="bottom"
showLegend
theme={npStart.plugins.eui_utils.useChartsTheme()}
/>
<Axis id="signalAxisX" position="bottom" tickFormat={timeFormatter(niceTimeFormatByDay(1))} />
<Axis id="signalAxisY" position="left" />
<HistogramBarSeries
id="signalBar"
xScaleType="time"
yScaleType="linear"
xAccessor="x"
yAccessors={['y']}
splitSeriesAccessors={['a', 'b']}
data={sampleChartData}
/>
</Chart>
);
});
HistogramSignals.displayName = 'HistogramSignals';

View file

@ -42,7 +42,7 @@ export const fetchQuerySignals = async <Hit, Aggregations>({
'content-type': 'application/json',
'kbn-xsrf': 'true',
},
body: query,
body: JSON.stringify(query),
signal,
});
await throwIfNotOk(response);

View file

@ -10,7 +10,7 @@ export interface BasicSignals {
signal: AbortSignal;
}
export interface QuerySignals extends BasicSignals {
query: string;
query: object;
}
export interface SignalsResponse {
@ -18,7 +18,8 @@ export interface SignalsResponse {
timeout: boolean;
}
export interface SignalSearchResponse<Hit = {}, Aggregations = undefined> extends SignalsResponse {
export interface SignalSearchResponse<Hit = {}, Aggregations = {} | undefined>
extends SignalsResponse {
_shards: {
total: number;
successful: number;

View file

@ -4,20 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import React, { SetStateAction, useEffect, useState } from 'react';
import { fetchQuerySignals } from './api';
import { SignalSearchResponse } from './types';
type Return<Hit, Aggs> = [boolean, SignalSearchResponse<Hit, Aggs> | null];
type Return<Hit, Aggs> = [
boolean,
SignalSearchResponse<Hit, Aggs> | null,
React.Dispatch<SetStateAction<object>>
];
/**
* Hook for using to get a Signals from the Detection Engine API
*
* @param query convert a dsl into string
* @param initialQuery query dsl object
*
*/
export const useQuerySignals = <Hit, Aggs>(query: string): Return<Hit, Aggs> => {
export const useQuerySignals = <Hit, Aggs>(initialQuery: object): Return<Hit, Aggs> => {
const [query, setQuery] = useState(initialQuery);
const [signals, setSignals] = useState<SignalSearchResponse<Hit, Aggs> | null>(null);
const [loading, setLoading] = useState(true);
@ -53,5 +58,5 @@ export const useQuerySignals = <Hit, Aggs>(query: string): Return<Hit, Aggs> =>
};
}, [query]);
return [loading, signals];
return [loading, signals, setQuery];
};

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiPanel, EuiSelect } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { memo } from 'react';
import { HeaderSection } from '../../../../components/header_section';
import { HistogramSignals } from '../../../../components/page/detection_engine/histogram_signals';
export const sampleChartOptions = [
{ text: 'Risk scores', value: 'risk_scores' },
{ text: 'Severities', value: 'severities' },
{ text: 'Top destination IPs', value: 'destination_ips' },
{ text: 'Top event actions', value: 'event_actions' },
{ text: 'Top event categories', value: 'event_categories' },
{ text: 'Top host names', value: 'host_names' },
{ text: 'Top rule types', value: 'rule_types' },
{ text: 'Top rules', value: 'rules' },
{ text: 'Top source IPs', value: 'source_ips' },
{ text: 'Top users', value: 'users' },
];
const SignalsChartsComponent = () => (
<EuiPanel>
<HeaderSection title="Signal detection frequency">
<EuiSelect
options={sampleChartOptions}
onChange={() => noop}
prepend="Stack by"
value={sampleChartOptions[0].value}
/>
</HeaderSection>
<HistogramSignals />
</EuiPanel>
);
export const SignalsCharts = memo(SignalsChartsComponent);

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 * as i18n from './translations';
import { SignalsHistogramOption } from './types';
export const signalsHistogramOptions: SignalsHistogramOption[] = [
{ text: i18n.STACK_BY_RISK_SCORES, value: 'signal.rule.risk_score' },
{ text: i18n.STACK_BY_SEVERITIES, value: 'signal.rule.severity' },
{ text: i18n.STACK_BY_DESTINATION_IPS, value: 'destination.ip' },
{ text: i18n.STACK_BY_ACTIONS, value: 'event.action' },
{ text: i18n.STACK_BY_CATEGORIES, value: 'event.category' },
{ text: i18n.STACK_BY_HOST_NAMES, value: 'host.name' },
{ text: i18n.STACK_BY_RULE_TYPES, value: 'signal.rule.type' },
{ text: i18n.STACK_BY_RULE_NAMES, value: 'signal.rule.name' },
{ text: i18n.STACK_BY_SOURCE_IPS, value: 'source.ip' },
{ text: i18n.STACK_BY_USERS, value: 'user.name' },
];

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 { Position } from '@elastic/charts';
import { EuiButton, EuiPanel, EuiSelect } from '@elastic/eui';
import numeral from '@elastic/numeral';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { HeaderSection } from '../../../../components/header_section';
import { SignalsHistogram } from './signals_histogram';
import * as i18n from './translations';
import { Query } from '../../../../../../../../../src/plugins/data/common/query';
import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query';
import { SignalsHistogramOption, SignalsTotal } from './types';
import { signalsHistogramOptions } from './config';
import { getDetectionEngineUrl } from '../../../../components/link_to';
import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants';
import { useUiSetting$ } from '../../../../lib/kibana';
const defaultTotalSignalsObj: SignalsTotal = {
value: 0,
relation: 'eq',
};
interface SignalsHistogramPanelProps {
defaultStackByOption?: SignalsHistogramOption;
filters?: esFilters.Filter[];
from: number;
query?: Query;
legendPosition?: Position;
loadingInitial?: boolean;
showLinkToSignals?: boolean;
showTotalSignalsCount?: boolean;
stackByOptions?: SignalsHistogramOption[];
title?: string;
to: number;
updateDateRange: (min: number, max: number) => void;
}
export const SignalsHistogramPanel = memo<SignalsHistogramPanelProps>(
({
defaultStackByOption = signalsHistogramOptions[0],
filters,
query,
from,
legendPosition = 'bottom',
loadingInitial = false,
showLinkToSignals = false,
showTotalSignalsCount = false,
stackByOptions,
to,
title = i18n.HISTOGRAM_HEADER,
updateDateRange,
}) => {
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
const [totalSignalsObj, setTotalSignalsObj] = useState<SignalsTotal>(defaultTotalSignalsObj);
const [selectedStackByOption, setSelectedStackByOption] = useState<SignalsHistogramOption>(
defaultStackByOption
);
const totalSignals = useMemo(
() =>
i18n.SHOWING_SIGNALS(
numeral(totalSignalsObj.value).format(defaultNumberFormat),
totalSignalsObj.value,
totalSignalsObj.relation === 'gte' ? '>' : totalSignalsObj.relation === 'lte' ? '<' : ''
),
[totalSignalsObj]
);
const setSelectedOptionCallback = useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedStackByOption(
stackByOptions?.find(co => co.value === event.target.value) ?? defaultStackByOption
);
}, []);
return (
<EuiPanel>
<HeaderSection title={title} subtitle={showTotalSignalsCount && totalSignals}>
{stackByOptions && (
<EuiSelect
onChange={setSelectedOptionCallback}
options={stackByOptions}
prepend={i18n.STACK_BY_LABEL}
value={selectedStackByOption.value}
/>
)}
{showLinkToSignals && (
<EuiButton href={getDetectionEngineUrl()}>{i18n.VIEW_SIGNALS}</EuiButton>
)}
</HeaderSection>
<SignalsHistogram
filters={filters}
from={from}
legendPosition={legendPosition}
loadingInitial={loadingInitial}
query={query}
to={to}
setTotalSignalsCount={setTotalSignalsObj}
stackByField={selectedStackByOption.value}
updateDateRange={updateDateRange}
/>
</EuiPanel>
);
}
);
SignalsHistogramPanel.displayName = 'SignalsHistogramPanel';

View file

@ -0,0 +1,73 @@
/*
* 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 { HistogramData, SignalsAggregation, SignalsBucket, SignalsGroupBucket } from '../types';
import { SignalSearchResponse } from '../../../../../containers/detection_engine/signals/types';
import * as i18n from '../translations';
export const formatSignalsData = (
signalsData: SignalSearchResponse<{}, SignalsAggregation> | null
) => {
const groupBuckets: SignalsGroupBucket[] =
signalsData?.aggregations?.signalsByGrouping?.buckets ?? [];
return groupBuckets.reduce<HistogramData[]>((acc, { key: group, signals }) => {
const signalsBucket: SignalsBucket[] = signals.buckets ?? [];
return [
...acc,
...signalsBucket.map(({ key, doc_count }: SignalsBucket) => ({
x: key,
y: doc_count,
g: group,
})),
];
}, []);
};
export const getSignalsHistogramQuery = (
stackByField: string,
from: number,
to: number,
additionalFilters: Array<{
bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] };
}>
) => ({
aggs: {
signalsByGrouping: {
terms: {
field: stackByField,
missing: stackByField.endsWith('.ip') ? '0.0.0.0' : i18n.ALL_OTHERS,
order: {
_count: 'desc',
},
size: 10,
},
aggs: {
signals: {
auto_date_histogram: {
field: '@timestamp',
buckets: 36,
},
},
},
},
},
query: {
bool: {
filter: [
...additionalFilters,
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
],
},
},
});

View file

@ -0,0 +1,122 @@
/*
* 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 {
Axis,
Chart,
getAxisId,
getSpecId,
HistogramBarSeries,
niceTimeFormatByDay,
Position,
Settings,
timeFormatter,
} from '@elastic/charts';
import React, { useEffect, useMemo } from 'react';
import { EuiLoadingContent } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import { useQuerySignals } from '../../../../../containers/detection_engine/signals/use_query';
import { Query } from '../../../../../../../../../../src/plugins/data/common/query';
import { esFilters, esQuery } from '../../../../../../../../../../src/plugins/data/common/es_query';
import { SignalsAggregation, SignalsTotal } from '../types';
import { formatSignalsData, getSignalsHistogramQuery } from './helpers';
import { useTheme } from '../../../../../components/charts/common';
import { useKibana } from '../../../../../lib/kibana';
interface HistogramSignalsProps {
filters?: esFilters.Filter[];
from: number;
legendPosition?: Position;
loadingInitial: boolean;
query?: Query;
setTotalSignalsCount: React.Dispatch<SignalsTotal>;
stackByField: string;
to: number;
updateDateRange: (min: number, max: number) => void;
}
export const SignalsHistogram = React.memo<HistogramSignalsProps>(
({
to,
from,
query,
filters,
legendPosition = 'bottom',
loadingInitial,
setTotalSignalsCount,
stackByField,
updateDateRange,
}) => {
const [isLoadingSignals, signalsData, setQuery] = useQuerySignals<{}, SignalsAggregation>(
getSignalsHistogramQuery(stackByField, from, to, [])
);
const theme = useTheme();
const kibana = useKibana();
const formattedSignalsData = useMemo(() => formatSignalsData(signalsData), [signalsData]);
useEffect(() => {
setTotalSignalsCount(
signalsData?.hits.total ?? {
value: 0,
relation: 'eq',
}
);
}, [signalsData]);
useEffect(() => {
const converted = esQuery.buildEsQuery(
undefined,
query != null ? [query] : [],
filters?.filter(f => f.meta.disabled === false) ?? [],
{
...esQuery.getEsQueryConfig(kibana.services.uiSettings),
dateFormatTZ: undefined,
}
);
setQuery(
getSignalsHistogramQuery(stackByField, from, to, !isEmpty(converted) ? [converted] : [])
);
}, [stackByField, from, to, query, filters]);
return (
<>
{loadingInitial || isLoadingSignals ? (
<EuiLoadingContent data-test-subj="loadingPanelSignalsHistogram" lines={10} />
) : (
<Chart size={['100%', 259]}>
<Settings
legendPosition={legendPosition}
onBrushEnd={updateDateRange}
showLegend
theme={theme}
/>
<Axis
id={getAxisId('signalsHistogramAxisX')}
position="bottom"
tickFormat={timeFormatter(niceTimeFormatByDay(1))}
/>
<Axis id={getAxisId('signalsHistogramAxisY')} position="left" />
<HistogramBarSeries
id={getSpecId('signalsHistogram')}
xScaleType="time"
yScaleType="linear"
xAccessor="x"
yAccessors={['y']}
splitSeriesAccessors={['g']}
data={formattedSignalsData}
/>
</Chart>
)}
</>
);
}
);
SignalsHistogram.displayName = 'SignalsHistogram';

View file

@ -0,0 +1,116 @@
/*
* 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 STACK_BY_LABEL = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.stackByLabel',
{
defaultMessage: 'Stack by',
}
);
export const STACK_BY_RISK_SCORES = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.riskScoresDropDown',
{
defaultMessage: 'Risk scores',
}
);
export const STACK_BY_SEVERITIES = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.severitiesDropDown',
{
defaultMessage: 'Severities',
}
);
export const STACK_BY_DESTINATION_IPS = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.destinationIpsDropDown',
{
defaultMessage: 'Top destination IPs',
}
);
export const STACK_BY_SOURCE_IPS = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.sourceIpsDropDown',
{
defaultMessage: 'Top source IPs',
}
);
export const STACK_BY_ACTIONS = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventActionsDropDown',
{
defaultMessage: 'Top event actions',
}
);
export const STACK_BY_CATEGORIES = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventCategoriesDropDown',
{
defaultMessage: 'Top event categories',
}
);
export const STACK_BY_HOST_NAMES = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.hostNamesDropDown',
{
defaultMessage: 'Top host names',
}
);
export const STACK_BY_RULE_TYPES = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.ruleTypesDropDown',
{
defaultMessage: 'Top rule types',
}
);
export const STACK_BY_RULE_NAMES = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.rulesDropDown',
{
defaultMessage: 'Top rules',
}
);
export const STACK_BY_USERS = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.stackByOptions.usersDropDown',
{
defaultMessage: 'Top users',
}
);
export const HISTOGRAM_HEADER = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.headerTitle',
{
defaultMessage: 'Signal detection frequency',
}
);
export const ALL_OTHERS = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.allOthersGroupingLabel',
{
defaultMessage: 'All others',
}
);
export const VIEW_SIGNALS = i18n.translate(
'xpack.siem.detectionEngine.signals.histogram.viewSignalsButtonLabel',
{
defaultMessage: 'View signals',
}
);
export const SHOWING_SIGNALS = (
totalSignalsFormatted: string,
totalSignals: number,
modifier: string
) =>
i18n.translate('xpack.siem.detectionEngine.signals.histogram.showingSignalsTitle', {
values: { totalSignalsFormatted, totalSignals, modifier },
defaultMessage:
'Showing: {modifier}{totalSignalsFormatted} {totalSignals, plural, =1 {signal} other {signals}}',
});

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.
*/
export interface SignalsHistogramOption {
text: string;
value: string;
}
export interface HistogramData {
x: number;
y: number;
g: string;
}
export interface SignalsAggregation {
signalsByGrouping: {
buckets: SignalsGroupBucket[];
};
}
export interface SignalsBucket {
key_as_string: string;
key: number;
doc_count: number;
}
export interface SignalsGroupBucket {
key: string;
signals: {
buckets: SignalsBucket[];
};
}
export interface SignalsTotal {
value: number;
relation: string;
}

View file

@ -9,7 +9,7 @@ import { FormattedRelative } from '@kbn/i18n/react';
import React, { useState, useEffect } from 'react';
import { useQuerySignals } from '../../../../containers/detection_engine/signals/use_query';
import { buildlastSignalsQuery } from './query.dsl';
import { buildLastSignalsQuery } from './query.dsl';
import { Aggs } from './types';
interface SignalInfo {
@ -26,14 +26,7 @@ export const useSignalInfo = ({ ruleId = null }: SignalInfo): Return => {
<EuiLoadingSpinner size="m" />
);
let query = '';
try {
query = JSON.stringify(buildlastSignalsQuery(ruleId));
} catch {
query = '';
}
const [loading, signals] = useQuerySignals<unknown, Aggs>(query);
const [loading, signals] = useQuerySignals<unknown, Aggs>(buildLastSignalsQuery(ruleId));
useEffect(() => {
if (signals != null) {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const buildlastSignalsQuery = (ruleId: string | undefined | null) => {
export const buildLastSignalsQuery = (ruleId: string | undefined | null) => {
const queryFilter = [
{
bool: { should: [{ match: { 'signal.status': 'open' } }], minimum_should_match: 1 },

View file

@ -4,10 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiSpacer, EuiPanel, EuiLoadingContent } from '@elastic/eui';
import React from 'react';
import { EuiButton, EuiLoadingContent, EuiPanel, EuiSpacer } from '@elastic/eui';
import React, { useCallback } from 'react';
import { StickyContainer } from 'react-sticky';
import { connect } from 'react-redux';
import { ActionCreator } from 'typescript-fsa';
import { FiltersGlobal } from '../../components/filters_global';
import { HeaderPage } from '../../components/header_page';
import { SiemSearchBar } from '../../components/search_bar';
@ -18,24 +20,63 @@ import { SpyRoute } from '../../utils/route/spy_routes';
import { SignalsTable } from './components/signals';
import * as signalsI18n from './components/signals/translations';
import { SignalsCharts } from './components/signals_chart';
import { SignalsHistogramPanel } from './components/signals_histogram_panel';
import { Query } from '../../../../../../../src/plugins/data/common/query';
import { esFilters } from '../../../../../../../src/plugins/data/common/es_query';
import { inputsSelectors } from '../../store/inputs';
import { State } from '../../store';
import { InputsRange } from '../../store/inputs/model';
import { signalsHistogramOptions } from './components/signals_histogram_panel/config';
import { useSignalInfo } from './components/signals_info';
import { DetectionEngineEmptyPage } from './detection_engine_empty_page';
import { DetectionEngineNoIndex } from './detection_engine_no_signal_index';
import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated';
import * as i18n from './translations';
import { HeaderSection } from '../../components/header_section';
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions';
import { InputsModelId } from '../../store/inputs/constants';
interface DetectionEngineComponentProps {
interface OwnProps {
loading: boolean;
isSignalIndexExists: boolean | null;
isUserAuthenticated: boolean | null;
signalsIndex: string | null;
}
interface ReduxProps {
filters: esFilters.Filter[];
query: Query;
}
export interface DispatchProps {
setAbsoluteRangeDatePicker: ActionCreator<{
id: InputsModelId;
from: number;
to: number;
}>;
}
type DetectionEngineComponentProps = OwnProps & ReduxProps & DispatchProps;
export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps>(
({ loading, isSignalIndexExists, isUserAuthenticated, signalsIndex }) => {
({
filters,
loading,
isSignalIndexExists,
isUserAuthenticated,
query,
setAbsoluteRangeDatePicker,
signalsIndex,
}) => {
const [lastSignals] = useSignalInfo({});
const updateDateRangeCallback = useCallback(
(min: number, max: number) => {
setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max });
},
[setAbsoluteRangeDatePicker]
);
if (isUserAuthenticated != null && !isUserAuthenticated && !loading) {
return (
<WrapperPage>
@ -81,22 +122,33 @@ export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps
</EuiButton>
</HeaderPage>
<SignalsCharts />
<EuiSpacer />
<GlobalTime>
{({ to, from }) =>
!loading ? (
isSignalIndexExists && (
<SignalsTable from={from} signalsIndex={signalsIndex ?? ''} to={to} />
)
) : (
<EuiPanel>
<HeaderSection title={signalsI18n.SIGNALS_TABLE_TITLE} />
<EuiLoadingContent />
</EuiPanel>
)
}
{({ to, from }) => (
<>
<SignalsHistogramPanel
filters={filters}
from={from}
loadingInitial={loading}
query={query}
stackByOptions={signalsHistogramOptions}
to={to}
updateDateRange={updateDateRangeCallback}
/>
<EuiSpacer />
{!loading ? (
isSignalIndexExists && (
<SignalsTable from={from} signalsIndex={signalsIndex ?? ''} to={to} />
)
) : (
<EuiPanel>
<HeaderSection title={signalsI18n.SIGNALS_TABLE_TITLE} />
<EuiLoadingContent />
</EuiPanel>
)}
</>
)}
</GlobalTime>
</WrapperPage>
</StickyContainer>
@ -115,3 +167,22 @@ export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps
}
);
DetectionEngineComponent.displayName = 'DetectionEngineComponent';
const makeMapStateToProps = () => {
const getGlobalInputs = inputsSelectors.globalSelector();
return (state: State) => {
const globalInputs: InputsRange = getGlobalInputs(state);
const { query, filters } = globalInputs;
return {
query,
filters,
};
};
};
export const DetectionEngine = connect(makeMapStateToProps, {
setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker,
})(DetectionEngineComponent);
DetectionEngine.displayName = 'DetectionEngine';

View file

@ -11,9 +11,9 @@ import { useSignalIndex } from '../../containers/detection_engine/signals/use_si
import { usePrivilegeUser } from '../../containers/detection_engine/signals/use_privilege_user';
import { CreateRuleComponent } from './rules/create';
import { DetectionEngineComponent } from './detection_engine';
import { DetectionEngine } from './detection_engine';
import { EditRuleComponent } from './rules/edit';
import { RuleDetailsComponent } from './rules/details';
import { RuleDetails } from './rules/details';
import { RulesComponent } from './rules';
const detectionEnginePath = `/:pageName(detection-engine)`;
@ -44,7 +44,7 @@ export const DetectionEngineContainer = React.memo<Props>(() => {
return (
<Switch>
<Route exact path={detectionEnginePath} strict>
<DetectionEngineComponent
<DetectionEngine
loading={indexNameLoading || privilegeLoading}
isSignalIndexExists={isSignalIndexExists}
isUserAuthenticated={isAuthenticated}
@ -60,7 +60,7 @@ export const DetectionEngineContainer = React.memo<Props>(() => {
<CreateRuleComponent />
</Route>
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId`}>
<RuleDetailsComponent signalsIndex={signalIndexName} />
<RuleDetails signalsIndex={signalIndexName} />
</Route>
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId/edit`}>
<EditRuleComponent />

View file

@ -6,10 +6,12 @@
import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { memo, useMemo } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { StickyContainer } from 'react-sticky';
import { ActionCreator } from 'typescript-fsa';
import { connect } from 'react-redux';
import { FiltersGlobal } from '../../../../components/filters_global';
import { FormattedDate } from '../../../../components/formatted_date';
import { HeaderPage } from '../../../../components/header_page';
@ -24,7 +26,7 @@ import {
} from '../../../../containers/source';
import { SpyRoute } from '../../../../utils/route/spy_routes';
import { SignalsCharts } from '../../components/signals_chart';
import { SignalsHistogramPanel } from '../../components/signals_histogram_panel';
import { SignalsTable } from '../../components/signals';
import { DetectionEngineEmptyPage } from '../../detection_engine_empty_page';
import { useSignalInfo } from '../../components/signals_info';
@ -39,198 +41,261 @@ import { getStepsData } from '../helpers';
import * as ruleI18n from '../translations';
import * as i18n from './translations';
import { GlobalTime } from '../../../../containers/global_time';
import { signalsHistogramOptions } from '../../components/signals_histogram_panel/config';
import { InputsModelId } from '../../../../store/inputs/constants';
import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query';
import { Query } from '../../../../../../../../../src/plugins/data/common/query';
import { inputsSelectors } from '../../../../store/inputs';
import { State } from '../../../../store';
import { InputsRange } from '../../../../store/inputs/model';
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions';
interface RuleDetailsComponentProps {
interface OwnProps {
signalsIndex: string | null;
}
export const RuleDetailsComponent = memo<RuleDetailsComponentProps>(({ signalsIndex }) => {
const { ruleId } = useParams();
const [loading, rule] = useRule(ruleId);
const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({
rule,
detailsView: true,
});
const [lastSignals] = useSignalInfo({ ruleId });
interface ReduxProps {
filters: esFilters.Filter[];
query: Query;
}
const title = loading === true || rule === null ? <EuiLoadingSpinner size="m" /> : rule.name;
const subTitle = useMemo(
() =>
loading === true || rule === null ? (
<EuiLoadingSpinner size="m" />
) : (
[
<FormattedMessage
id="xpack.siem.detectionEngine.ruleDetails.ruleCreationDescription"
defaultMessage="Created by: {by} on {date}"
values={{
by: rule?.created_by ?? i18n.UNKNOWN,
date: (
<FormattedDate
value={rule?.created_at ?? new Date().toISOString()}
fieldName="createdAt"
/>
),
}}
/>,
rule?.updated_by != null ? (
export interface DispatchProps {
setAbsoluteRangeDatePicker: ActionCreator<{
id: InputsModelId;
from: number;
to: number;
}>;
}
type RuleDetailsComponentProps = OwnProps & ReduxProps & DispatchProps;
const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
({ filters, query, setAbsoluteRangeDatePicker, signalsIndex }) => {
const { ruleId } = useParams();
const [loading, rule] = useRule(ruleId);
const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({
rule,
detailsView: true,
});
const [lastSignals] = useSignalInfo({ ruleId });
const title = loading === true || rule === null ? <EuiLoadingSpinner size="m" /> : rule.name;
const subTitle = useMemo(
() =>
loading === true || rule === null ? (
<EuiLoadingSpinner size="m" />
) : (
[
<FormattedMessage
id="xpack.siem.detectionEngine.ruleDetails.ruleUpdateDescription"
defaultMessage="Updated by: {by} on {date}"
id="xpack.siem.detectionEngine.ruleDetails.ruleCreationDescription"
defaultMessage="Created by: {by} on {date}"
values={{
by: rule?.updated_by ?? i18n.UNKNOWN,
by: rule?.created_by ?? i18n.UNKNOWN,
date: (
<FormattedDate
value={rule?.updated_at ?? new Date().toISOString()}
fieldName="updatedAt"
value={rule?.created_at ?? new Date().toISOString()}
fieldName="createdAt"
/>
),
}}
/>
) : (
''
),
]
),
[loading, rule]
);
/>,
rule?.updated_by != null ? (
<FormattedMessage
id="xpack.siem.detectionEngine.ruleDetails.ruleUpdateDescription"
defaultMessage="Updated by: {by} on {date}"
values={{
by: rule?.updated_by ?? i18n.UNKNOWN,
date: (
<FormattedDate
value={rule?.updated_at ?? new Date().toISOString()}
fieldName="updatedAt"
/>
),
}}
/>
) : (
''
),
]
),
[loading, rule]
);
const signalDefaultFilters = useMemo(
() => (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []),
[ruleId]
);
return (
<>
<WithSource sourceId="default">
{({ indicesExist, indexPattern }) => {
return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? (
<GlobalTime>
{({ to, from }) => (
<StickyContainer>
<FiltersGlobal>
<SiemSearchBar id="global" indexPattern={indexPattern} />
</FiltersGlobal>
const signalDefaultFilters = useMemo(
() => (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []),
[ruleId]
);
<WrapperPage>
<HeaderPage
backOptions={{
href: `#${DETECTION_ENGINE_PAGE_NAME}/rules`,
text: i18n.BACK_TO_RULES,
}}
badgeOptions={{ text: i18n.EXPERIMENTAL }}
border
subtitle={subTitle}
subtitle2={[
lastSignals != null ? (
<>
{detectionI18n.LAST_SIGNAL}
{': '}
{lastSignals}
</>
) : null,
'Status: Comming Soon',
]}
title={title}
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<RuleSwitch
id={rule?.id ?? '-1'}
enabled={rule?.enabled ?? false}
optionLabel={i18n.ACTIVATE_RULE}
/>
const signalMergedFilters = useMemo(() => [...signalDefaultFilters, ...filters], [
signalDefaultFilters,
filters,
]);
const updateDateRangeCallback = useCallback(
(min: number, max: number) => {
setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max });
},
[setAbsoluteRangeDatePicker]
);
return (
<>
<WithSource sourceId="default">
{({ indicesExist, indexPattern }) => {
return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? (
<GlobalTime>
{({ to, from }) => (
<StickyContainer>
<FiltersGlobal>
<SiemSearchBar id="global" indexPattern={indexPattern} />
</FiltersGlobal>
<WrapperPage>
<HeaderPage
backOptions={{
href: `#${DETECTION_ENGINE_PAGE_NAME}/rules`,
text: i18n.BACK_TO_RULES,
}}
badgeOptions={{ text: i18n.EXPERIMENTAL }}
border
subtitle={subTitle}
subtitle2={[
lastSignals != null ? (
<>
{detectionI18n.LAST_SIGNAL}
{': '}
{lastSignals}
</>
) : null,
'Status: Comming Soon',
]}
title={title}
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<RuleSwitch
id={rule?.id ?? '-1'}
enabled={rule?.enabled ?? false}
optionLabel={i18n.ACTIVATE_RULE}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
href={`#${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}/edit`}
iconType="visControls"
isDisabled={rule?.immutable ?? true}
>
{ruleI18n.EDIT_RULE_SETTINGS}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</HeaderPage>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={loading} title={ruleI18n.DEFINITION}>
{defineRuleData != null && (
<StepDefineRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={defineRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
href={`#${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}/edit`}
iconType="visControls"
isDisabled={rule?.immutable ?? true}
>
{ruleI18n.EDIT_RULE_SETTINGS}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem component="section" grow={2}>
<StepPanel loading={loading} title={ruleI18n.ABOUT}>
{aboutRuleData != null && (
<StepAboutRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={aboutRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={loading} title={ruleI18n.SCHEDULE}>
{scheduleRuleData != null && (
<StepScheduleRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={scheduleRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
</EuiFlexGroup>
</HeaderPage>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={loading} title={ruleI18n.DEFINITION}>
{defineRuleData != null && (
<StepDefineRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={defineRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexItem component="section" grow={2}>
<StepPanel loading={loading} title={ruleI18n.ABOUT}>
{aboutRuleData != null && (
<StepAboutRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={aboutRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
<EuiFlexItem component="section" grow={1}>
<StepPanel loading={loading} title={ruleI18n.SCHEDULE}>
{scheduleRuleData != null && (
<StepScheduleRule
descriptionDirection="column"
isReadOnlyView={true}
isLoading={false}
defaultValues={scheduleRuleData}
/>
)}
</StepPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<SignalsCharts />
<EuiSpacer />
{ruleId != null && (
<SignalsTable
defaultFilters={signalDefaultFilters}
<EuiSpacer />
<SignalsHistogramPanel
filters={signalMergedFilters}
query={query}
from={from}
signalsIndex={signalsIndex ?? ''}
stackByOptions={signalsHistogramOptions}
to={to}
updateDateRange={updateDateRangeCallback}
/>
)}
</WrapperPage>
</StickyContainer>
)}
</GlobalTime>
) : (
<WrapperPage>
<HeaderPage border title={i18n.PAGE_TITLE} />
<DetectionEngineEmptyPage />
</WrapperPage>
);
}}
</WithSource>
<EuiSpacer />
<SpyRoute />
</>
);
});
{ruleId != null && (
<SignalsTable
defaultFilters={signalDefaultFilters}
from={from}
signalsIndex={signalsIndex ?? ''}
to={to}
/>
)}
</WrapperPage>
</StickyContainer>
)}
</GlobalTime>
) : (
<WrapperPage>
<HeaderPage border title={i18n.PAGE_TITLE} />
<DetectionEngineEmptyPage />
</WrapperPage>
);
}}
</WithSource>
<SpyRoute />
</>
);
}
);
RuleDetailsComponent.displayName = 'RuleDetailsComponent';
const makeMapStateToProps = () => {
const getGlobalInputs = inputsSelectors.globalSelector();
return (state: State) => {
const globalInputs: InputsRange = getGlobalInputs(state);
const { query, filters } = globalInputs;
return {
query,
filters,
};
};
};
export const RuleDetails = connect(makeMapStateToProps, {
setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker,
})(RuleDetailsComponent);
RuleDetails.displayName = 'RuleDetails';