Skip to content

Commit

Permalink
## [SIEM] Threat hunting enhancements: Filter for/out value, Show top…
Browse files Browse the repository at this point in the history
… field, Copy to Clipboard, Draggable chart legends

Enhancements to the threat hunting experience

![show-top-field](https://user-images.githubusercontent.com/4459398/79180753-f9bb7f80-7dc7-11ea-9ae2-d4e4fc79208c.gif)

### New draggable context menu

A new context menu with the following items has been added to all draggables:

- Filter for value
- Filter out value
- Show top _field name_
- Copy to Clipboard

as shown in the following animated gif:

![new-context-menu](https://user-images.githubusercontent.com/4459398/79173935-4dbd6880-7db6-11ea-9253-7746481e1b17.gif)

### Filter for value

The _Filter for value_ context menu action adds the draggable to the global filter bar, which is applicable to all pages in the SIEM app, per the following animated gif:

![filter-in-value](https://user-images.githubusercontent.com/4459398/79176624-f91deb80-7dbd-11ea-9b01-799145d776c8.gif)

### Filter out value

The _Filter out value_ context menu action adds the draggable to the global filter bar as a _negated_ (`NOT`) filter, per the following animated gif:

![filter-out-value](https://user-images.githubusercontent.com/4459398/79178474-9f6bf000-7dc2-11ea-9423-512ad7f89a18.gif)

### Show top _field_

The _Show top field_ context menu action displays an interactive Top 10 histogram, per the following animated gif:

![show-top-field](https://user-images.githubusercontent.com/4459398/79180753-f9bb7f80-7dc7-11ea-9ae2-d4e4fc79208c.gif)

- The contents of the histogram are filtered by the global KQL bar / filters and current date range
- Brushing over the bars in the histogram updates the global date range / picker
- Select _Events_ or _Signals_
- The _Show top field_ action is also available in the Fields Browser, per the following animated gif:

![in-fields-browser](https://user-images.githubusercontent.com/4459398/79179548-1a360a80-7dc5-11ea-9ad7-cdd7fef0cc64.gif)

### Copy to Clipboard

The _Copy to clipboard_ context menu action copies the draggable field and value to the clipboard in KQL format (e.g. `process.name: "nice"`).

Per the following animated gifs, it's now possible to copy _any_ draggable to the clipboard, and paste it in KQL format, which addresses [this feature request from a user](#59472):

![copy-to-clipboard](https://user-images.githubusercontent.com/4459398/79178893-a7785f80-7dc3-11ea-868a-5d7bc2824912.gif)

![pasted-value](https://user-images.githubusercontent.com/4459398/79179126-2c637900-7dc4-11ea-92a7-86c7d6377688.gif)

### Draggable chart legends

You may now pivot from chart legends by dragging and dropping them to a timeline, or by selecting the Filter for / out context menu action, per the following animated gif:

![draggable-legend](https://user-images.githubusercontent.com/4459398/79179769-9deff700-7dc5-11ea-9153-b472914f2dfe.gif)

#### Desk testing

Desk tested in:

- Chrome `81.0.4044.92`
- Firefox `75.0`
- Safari `13.1`
  • Loading branch information
andrew-goldstein committed Apr 15, 2020
1 parent ac549ac commit ed082f7
Show file tree
Hide file tree
Showing 80 changed files with 3,538 additions and 661 deletions.
17 changes: 17 additions & 0 deletions x-pack/legacy/plugins/siem/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,20 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
];
export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions';
export const NOTIFICATION_THROTTLE_RULE = 'rule';

/**
* Histograms for fields named in this list should be displayed with an
* "All others" bucket, to count events that don't specify a value for
* the field being counted
*/
export const showAllOthersBucket: string[] = [
'destination.ip',
'event.action',
'event.category',
'event.dataset',
'event.module',
'signal.rule.threat.tactic.name',
'source.ip',
'destination.ip',
'user.name',
];
116 changes: 114 additions & 2 deletions x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,29 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { shallow, ShallowWrapper } from 'enzyme';
import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts';
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme';
import React from 'react';
import { ThemeProvider } from 'styled-components';

import { escapeDataProviderId } from '../drag_and_drop/helpers';
import { TestProviders } from '../../mock';

import { BarChartBaseComponent, BarChartComponent } from './barchart';
import { ChartSeriesData } from './common';
import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts';

jest.mock('../../lib/kibana');

jest.mock('uuid', () => {
return {
v1: jest.fn(() => 'uuid.v1()'),
v4: jest.fn(() => 'uuid.v4()'),
};
});

const theme = () => ({ eui: euiDarkVars, darkMode: true });

const customHeight = '100px';
const customWidth = '120px';
const chartDataSets = [
Expand Down Expand Up @@ -116,6 +130,19 @@ const mockConfig = {
customHeight: 324,
};

// Suppress warnings about "react-beautiful-dnd"
/* eslint-disable no-console */
const originalError = console.error;
const originalWarn = console.warn;
beforeAll(() => {
console.warn = jest.fn();
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
console.warn = originalWarn;
});

describe('BarChartBaseComponent', () => {
let shallowWrapper: ShallowWrapper;
const mockBarChartData: ChartSeriesData[] = [
Expand Down Expand Up @@ -280,6 +307,91 @@ describe.each(chartDataSets)('BarChart with valid data [%o]', data => {
expect(shallowWrapper.find('BarChartBase')).toHaveLength(1);
expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0);
});

it('it does NOT render a draggable legend because stackByField is not provided', () => {
expect(shallowWrapper.find('[data-test-subj="draggable-legend"]').exists()).toBe(false);
});
});

describe.each(chartDataSets)('BarChart with stackByField', () => {
let wrapper: ReactWrapper;

const data = [
{
key: 'python.exe',
value: [
{
x: 1586754900000,
y: 9675,
g: 'python.exe',
},
],
},
{
key: 'kernel',
value: [
{
x: 1586754900000,
y: 8708,
g: 'kernel',
},
{
x: 1586757600000,
y: 9282,
g: 'kernel',
},
],
},
{
key: 'sshd',
value: [
{
x: 1586754900000,
y: 5907,
g: 'sshd',
},
],
},
];

const expectedColors = ['#1EA593', '#2B70F7', '#CE0060'];

const stackByField = 'process.name';

beforeAll(() => {
wrapper = mount(
<ThemeProvider theme={theme}>
<TestProviders>
<BarChartComponent configs={mockConfig} barChart={data} stackByField={stackByField} />
</TestProviders>
</ThemeProvider>
);
});

it('it renders a draggable legend', () => {
expect(wrapper.find('[data-test-subj="draggable-legend"]').exists()).toBe(true);
});

expectedColors.forEach((color, i) => {
test(`it renders the expected legend color ${color} for legend item ${i}`, () => {
expect(wrapper.find(`div [color="${color}"]`).exists()).toBe(true);
});
});

data.forEach(datum => {
test(`it renders the expected draggable legend text for datum ${datum.key}`, () => {
const dataProviderId = `draggableId.content.draggable-legend-item-uuid_v4()-${escapeDataProviderId(
stackByField
)}-${escapeDataProviderId(datum.key)}`;

expect(
wrapper
.find(`div [data-rbd-draggable-id="${dataProviderId}"]`)
.first()
.text()
).toEqual(datum.key);
});
});
});

describe.each(chartHolderDataSets)('BarChart with invalid data [%o]', data => {
Expand Down
65 changes: 57 additions & 8 deletions x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import { Chart, BarSeries, Axis, Position, ScaleType, Settings } from '@elastic/charts';
import { getOr, get, isNumber } from 'lodash/fp';
import deepmerge from 'deepmerge';
import uuid from 'uuid';
import styled from 'styled-components';

import { useThrottledResizeObserver } from '../utils';
import { escapeDataProviderId } from '../drag_and_drop/helpers';
import { useTimeZone } from '../../lib/kibana';
import { defaultLegendColors } from '../matrix_histogram/utils';
import { useThrottledResizeObserver } from '../utils';

import { ChartPlaceHolder } from './chart_place_holder';
import {
chartDefaultSettings,
Expand All @@ -22,6 +28,12 @@ import {
WrappedByAutoSizer,
useTheme,
} from './common';
import { DraggableLegend } from './draggable_legend';
import { LegendItem } from './draggable_legend_item';

const LegendFlexItem = styled(EuiFlexItem)`
overview: hidden;
`;

const checkIfAllTheDataInTheSeriesAreValid = (series: ChartSeriesData): series is ChartSeriesData =>
series != null &&
Expand All @@ -38,12 +50,14 @@ const checkIfAnyValidSeriesExist = (
// Bar chart rotation: https://ela.st/chart-rotations
export const BarChartBaseComponent = ({
data,
forceHiddenLegend = false,
...chartConfigs
}: {
data: ChartSeriesData[];
width: string | null | undefined;
height: string | null | undefined;
configs?: ChartSeriesConfigs | undefined;
forceHiddenLegend?: boolean;
}) => {
const theme = useTheme();
const timeZone = useTimeZone();
Expand All @@ -59,10 +73,10 @@ export const BarChartBaseComponent = ({

return chartConfigs.width && chartConfigs.height ? (
<Chart>
<Settings {...settings} />
<Settings {...settings} showLegend={settings.showLegend && !forceHiddenLegend} />
{data.map(series => {
const barSeriesKey = series.key;
return checkIfAllTheDataInTheSeriesAreValid ? (
return checkIfAllTheDataInTheSeriesAreValid(series) ? (
<BarSeries
id={barSeriesKey}
key={barSeriesKey}
Expand Down Expand Up @@ -102,19 +116,54 @@ BarChartBase.displayName = 'BarChartBase';
interface BarChartComponentProps {
barChart: ChartSeriesData[] | null | undefined;
configs?: ChartSeriesConfigs | undefined;
stackByField?: string;
}

export const BarChartComponent: React.FC<BarChartComponentProps> = ({ barChart, configs }) => {
const NO_LEGEND_DATA: LegendItem[] = [];

export const BarChartComponent: React.FC<BarChartComponentProps> = ({
barChart,
configs,
stackByField,
}) => {
const { ref: measureRef, width, height } = useThrottledResizeObserver();
const legendItems: LegendItem[] = useMemo(
() =>
barChart != null && stackByField != null
? barChart.map((d, i) => ({
color: d.color ?? i < defaultLegendColors.length ? defaultLegendColors[i] : undefined,
dataProviderId: escapeDataProviderId(
`draggable-legend-item-${uuid.v4()}-${stackByField}-${d.key}`
),
field: stackByField,
value: d.key,
}))
: NO_LEGEND_DATA,
[barChart, stackByField]
);

const customHeight = get('customHeight', configs);
const customWidth = get('customWidth', configs);
const chartHeight = getChartHeight(customHeight, height);
const chartWidth = getChartWidth(customWidth, width);

return checkIfAnyValidSeriesExist(barChart) ? (
<WrappedByAutoSizer ref={measureRef} height={chartHeight}>
<BarChartBase height={chartHeight} width={chartHeight} data={barChart} configs={configs} />
</WrappedByAutoSizer>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={true}>
<WrappedByAutoSizer ref={measureRef} height={chartHeight}>
<BarChartBase
configs={configs}
data={barChart}
forceHiddenLegend={stackByField != null}
height={chartHeight}
width={chartHeight}
/>
</WrappedByAutoSizer>
</EuiFlexItem>
<LegendFlexItem grow={false}>
<DraggableLegend legendItems={legendItems} height={height} />
</LegendFlexItem>
</EuiFlexGroup>
) : (
<ChartPlaceHolder height={chartHeight} width={chartWidth} data={barChart} />
);
Expand Down
Loading

0 comments on commit ed082f7

Please sign in to comment.