Skip to content

Commit

Permalink
[SIEM] Adds Signals Histogram (#53742)
Browse files Browse the repository at this point in the history
## 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)~
  • Loading branch information
spong committed Jan 10, 2020
1 parent 68883c6 commit 482faae
Show file tree
Hide file tree
Showing 18 changed files with 836 additions and 363 deletions.

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ export interface BasicSignals {
signal: AbortSignal;
}
export interface QuerySignals extends BasicSignals {
query: string;
query: object;
}

export interface SignalsResponse {
took: number;
timeout: boolean;
}

export interface SignalSearchResponse<Hit = {}, Aggregations = undefined> extends SignalsResponse {
export interface SignalSearchResponse<Hit = {}, Aggregations = {} | undefined>
extends SignalsResponse {
_shards: {
total: number;
successful: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -53,5 +58,5 @@ export const useQuerySignals = <Hit, Aggs>(query: string): Return<Hit, Aggs> =>
};
}, [query]);

return [loading, signals];
return [loading, signals, setQuery];
};

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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' },
];
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit 482faae

Please sign in to comment.