Skip to content

Commit

Permalink
[Security] Investigate in Resolver Timeline Integration (#70111)
Browse files Browse the repository at this point in the history
## [Security] `Investigate in Resolver` Timeline Integration

This PR adds a new `Investigate in Resolver` action to the Timeline, and all timeline-based views, including:

- Timeline
- Alert list (i.e. Signals)
- Hosts > Events
- Hosts > External alerts
- Network > External alerts

![investigate-in-resolver-action](https://user-images.githubusercontent.com/4459398/85886173-c40d1c80-b7a2-11ea-8011-0221fef95d51.png)

### Resolver Overlay

When the `Investigate in Resolver` action is clicked, Resolver is displayed in an overlay over the events. The screenshot below has placeholder text where Resolver will be rendered:

![resolver-overlay](https://user-images.githubusercontent.com/4459398/85886309-10f0f300-b7a3-11ea-95cb-0117207e4890.png)

The Resolver overlay is closed by clicking the `< Back to events` button shown in the screenshot above.

The state of the timeline is restored when the overlay is closed. The scroll position (within the events), any expanded events, etc, will appear exactly as they were before the Resolver overlay was displayed.

### Case Integration

Users may link directly to a Timeline Resolver view from cases via the `Attach to new case` and `Attach to existing case...` actions show in the screenshot below:

![case-integration](https://user-images.githubusercontent.com/4459398/85886773-e3587980-b7a3-11ea-87b6-b098ea14bc5f.png)

![investigate-in-resolver](https://user-images.githubusercontent.com/4459398/85885618-daff3f00-b7a1-11ea-9356-2e8a1291f213.gif)

When users click the link in a case, Timeline will automatically open to the Resolver view in the link.

### URL State

Users can directly share Resolver views (in saved Timelines) with other users by copying the Kibana URL to the clipboard when Resolver is open.

When another user pastes the URL in their browser, Timeline will automatically open and display the Resolver view in the URL.

### Enabling the `Investigate in Resolver` action

In this PR, the `Investigate in Resolver` action is only enabled for events where all of the following are true:

- `agent.type` is `endpoint`
- `process.entity_id` exists

### Context passed to Resolver

The only context passed to `Resolver` is the `_id` of the event (when the user clicks `Investigate in Resolver`)

### What's next?

- @oatkiller will replace the placeholder text shown in the screenshots above with the actual call to Resolver in a separate PR
- I will follow-up this PR with additional tests
- The action text `Investigate in Resolver` may be changed in a future PR
- Hide the `Add to case` action in timeline-based views (it's currently visible, but disabled)
  • Loading branch information
andrew-goldstein committed Jun 26, 2020
1 parent 69bc772 commit 384ff47
Show file tree
Hide file tree
Showing 57 changed files with 1,615 additions and 1,024 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import React from 'react';
import ApolloClient from 'apollo-client';
import { Dispatch } from 'redux';

import { EuiText } from '@elastic/eui';
import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
Expand All @@ -17,10 +18,12 @@ import {
TimelineRowActionOnClick,
} from '../../../timelines/components/timeline/body/actions';
import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers';
import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers';
import {
DEFAULT_COLUMN_MIN_WIDTH,
DEFAULT_DATE_COLUMN_MIN_WIDTH,
} from '../../../timelines/components/timeline/body/constants';
import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../timelines/components/timeline/helpers';
import { ColumnHeaderOptions, SubsetTimelineModel } from '../../../timelines/store/timeline/model';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';

Expand Down Expand Up @@ -174,23 +177,27 @@ export const getAlertActions = ({
apolloClient,
canUserCRUD,
createTimeline,
dispatch,
hasIndexWrite,
onAlertStatusUpdateFailure,
onAlertStatusUpdateSuccess,
setEventsDeleted,
setEventsLoading,
status,
timelineId,
updateTimelineIsLoading,
}: {
apolloClient?: ApolloClient<{}>;
canUserCRUD: boolean;
createTimeline: CreateTimeline;
dispatch: Dispatch;
hasIndexWrite: boolean;
onAlertStatusUpdateFailure: (status: Status, error: Error) => void;
onAlertStatusUpdateSuccess: (count: number, status: Status) => void;
setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void;
setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void;
status: Status;
timelineId: string;
updateTimelineIsLoading: UpdateTimelineLoading;
}): TimelineRowAction[] => {
const openAlertActionComponent: TimelineRowAction = {
Expand All @@ -199,7 +206,7 @@ export const getAlertActions = ({
dataTestSubj: 'open-alert-status',
displayType: 'contextMenu',
id: FILTER_OPEN,
isActionDisabled: !canUserCRUD || !hasIndexWrite,
isActionDisabled: () => !canUserCRUD || !hasIndexWrite,
onClick: ({ eventId }: TimelineRowActionOnClick) =>
updateAlertStatusAction({
alertIds: [eventId],
Expand All @@ -210,7 +217,7 @@ export const getAlertActions = ({
status,
selectedStatus: FILTER_OPEN,
}),
width: 26,
width: DEFAULT_ICON_BUTTON_WIDTH,
};

const closeAlertActionComponent: TimelineRowAction = {
Expand All @@ -219,7 +226,7 @@ export const getAlertActions = ({
dataTestSubj: 'close-alert-status',
displayType: 'contextMenu',
id: FILTER_CLOSED,
isActionDisabled: !canUserCRUD || !hasIndexWrite,
isActionDisabled: () => !canUserCRUD || !hasIndexWrite,
onClick: ({ eventId }: TimelineRowActionOnClick) =>
updateAlertStatusAction({
alertIds: [eventId],
Expand All @@ -230,7 +237,7 @@ export const getAlertActions = ({
status,
selectedStatus: FILTER_CLOSED,
}),
width: 26,
width: DEFAULT_ICON_BUTTON_WIDTH,
};

const inProgressAlertActionComponent: TimelineRowAction = {
Expand All @@ -239,7 +246,7 @@ export const getAlertActions = ({
dataTestSubj: 'in-progress-alert-status',
displayType: 'contextMenu',
id: FILTER_IN_PROGRESS,
isActionDisabled: !canUserCRUD || !hasIndexWrite,
isActionDisabled: () => !canUserCRUD || !hasIndexWrite,
onClick: ({ eventId }: TimelineRowActionOnClick) =>
updateAlertStatusAction({
alertIds: [eventId],
Expand All @@ -250,10 +257,13 @@ export const getAlertActions = ({
status,
selectedStatus: FILTER_IN_PROGRESS,
}),
width: 26,
width: DEFAULT_ICON_BUTTON_WIDTH,
};

return [
{
...getInvestigateInResolverAction({ dispatch, timelineId }),
},
{
ariaLabel: 'Send alert to timeline',
content: i18n.ACTION_INVESTIGATE_IN_TIMELINE,
Expand All @@ -268,7 +278,7 @@ export const getAlertActions = ({
ecsData,
updateTimelineIsLoading,
}),
width: 26,
width: DEFAULT_ICON_BUTTON_WIDTH,
},
// Context menu items
...(FILTER_OPEN !== status ? [openAlertActionComponent] : []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,40 @@
import React from 'react';
import { shallow } from 'enzyme';

import { TestProviders } from '../../../common/mock/test_providers';
import { TimelineId } from '../../../../common/types/timeline';
import { AlertsTableComponent } from './index';

describe('AlertsTableComponent', () => {
it('renders correctly', () => {
const wrapper = shallow(
<AlertsTableComponent
timelineId={TimelineId.test}
canUserCRUD
hasIndexWrite
from={0}
loading
signalsIndex="index"
to={1}
globalQuery={{
query: 'query',
language: 'language',
}}
globalFilters={[]}
deletedEventIds={[]}
loadingEventIds={[]}
selectedEventIds={{}}
isSelectAllChecked={false}
clearSelected={jest.fn()}
setEventsLoading={jest.fn()}
clearEventsLoading={jest.fn()}
setEventsDeleted={jest.fn()}
clearEventsDeleted={jest.fn()}
updateTimelineIsLoading={jest.fn()}
updateTimeline={jest.fn()}
/>
<TestProviders>
<AlertsTableComponent
timelineId={TimelineId.test}
canUserCRUD
hasIndexWrite
from={0}
loading
signalsIndex="index"
to={1}
globalQuery={{
query: 'query',
language: 'language',
}}
globalFilters={[]}
deletedEventIds={[]}
loadingEventIds={[]}
selectedEventIds={{}}
isSelectAllChecked={false}
clearSelected={jest.fn()}
setEventsLoading={jest.fn()}
clearEventsLoading={jest.fn()}
setEventsDeleted={jest.fn()}
clearEventsDeleted={jest.fn()}
updateTimelineIsLoading={jest.fn()}
updateTimeline={jest.fn()}
/>
</TestProviders>
);

expect(wrapper.find('[title="Alerts"]')).toBeTruthy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { EuiPanel, EuiLoadingContent } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { connect, ConnectedProps, useDispatch } from 'react-redux';
import { Dispatch } from 'redux';

import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
Expand Down Expand Up @@ -84,6 +84,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
updateTimeline,
updateTimelineIsLoading,
}) => {
const dispatch = useDispatch();
const [selectAll, setSelectAll] = useState(false);
const apolloClient = useApolloClient();

Expand Down Expand Up @@ -292,11 +293,13 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
getAlertActions({
apolloClient,
canUserCRUD,
dispatch,
hasIndexWrite,
createTimeline: createTimelineCallback,
setEventsLoading: setEventsLoadingCallback,
setEventsDeleted: setEventsDeletedCallback,
status: filterGroup,
timelineId,
updateTimelineIsLoading,
onAlertStatusUpdateSuccess,
onAlertStatusUpdateFailure,
Expand All @@ -305,10 +308,12 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
apolloClient,
canUserCRUD,
createTimelineCallback,
dispatch,
hasIndexWrite,
filterGroup,
setEventsLoadingCallback,
setEventsDeletedCallback,
timelineId,
updateTimelineIsLoading,
onAlertStatusUpdateSuccess,
onAlertStatusUpdateFailure,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
*/

import React, { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';

import { Filter } from '../../../../../../../src/plugins/data/public';
import { TimelineIdLiteral } from '../../../../common/types/timeline';
import { StatefulEventsViewer } from '../events_viewer';
import { alertsDefaultModel } from './default_headers';
import { useManageTimeline } from '../../../timelines/components/manage_timeline';
import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers';
import * as i18n from './translations';

export interface OwnProps {
end: number;
id: string;
Expand Down Expand Up @@ -64,8 +67,9 @@ const AlertsTableComponent: React.FC<Props> = ({
startDate,
pageFilters = [],
}) => {
const dispatch = useDispatch();
const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]);
const { initializeTimeline } = useManageTimeline();
const { initializeTimeline, setTimelineRowActions } = useManageTimeline();

useEffect(() => {
initializeTimeline({
Expand All @@ -75,6 +79,10 @@ const AlertsTableComponent: React.FC<Props> = ({
title: i18n.ALERTS_TABLE_TITLE,
unit: i18n.UNIT,
});
setTimelineRowActions({
id: timelineId,
timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId })],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { EuiPanel } from '@elastic/eui';
import { getOr, isEmpty, union } from 'lodash/fp';
import React, { useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';

Expand Down Expand Up @@ -34,6 +35,7 @@ import {
} from '../../../../../../../src/plugins/data/public';
import { inputsModel } from '../../store';
import { useManageTimeline } from '../../../timelines/components/manage_timeline';
import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers';

const DEFAULT_EVENTS_VIEWER_HEIGHT = 500;

Expand Down Expand Up @@ -91,6 +93,7 @@ const EventsViewerComponent: React.FC<Props> = ({
toggleColumn,
utilityBar,
}) => {
const dispatch = useDispatch();
const columnsHeader = isEmpty(columns) ? defaultHeaders : columns;
const kibana = useKibana();
const { filterManager } = useKibana().services.data.query;
Expand All @@ -100,7 +103,16 @@ const EventsViewerComponent: React.FC<Props> = ({
getManageTimelineById,
setIsTimelineLoading,
setTimelineFilterManager,
setTimelineRowActions,
} = useManageTimeline();

useEffect(() => {
setTimelineRowActions({
id,
timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId: id })],
});
}, [setTimelineRowActions, id, dispatch]);

useEffect(() => {
setIsTimelineLoading({ id, isLoading: isQueryLoading });
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -179,9 +191,7 @@ const EventsViewerComponent: React.FC<Props> = ({
<HeaderSection id={id} subtitle={utilityBar ? undefined : subtitle} title={title}>
{headerFilterGroup}
</HeaderSection>

{utilityBar?.(refetch, totalCountMinusDeleted)}

<EventsContainerLoading data-test-subj={`events-container-loading-${loading}`}>
<TimelineRefetch
id={id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const getMockObject = (
timeline: {
id: '',
isOpen: false,
graphEventId: '',
},
timerange: {
global: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe('SIEM Navigation', () => {
[CONSTANTS.timeline]: {
id: '',
isOpen: false,
graphEventId: '',
},
},
};
Expand Down Expand Up @@ -160,6 +161,7 @@ describe('SIEM Navigation', () => {
timeline: {
id: '',
isOpen: false,
graphEventId: '',
},
timerange: {
global: {
Expand Down Expand Up @@ -266,7 +268,7 @@ describe('SIEM Navigation', () => {
search: '',
state: undefined,
tabName: 'authentications',
timeline: { id: '', isOpen: false },
timeline: { id: '', isOpen: false, graphEventId: '' },
timerange: {
global: {
linkTo: ['timeline'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe('Tab Navigation', () => {
[CONSTANTS.timeline]: {
id: '',
isOpen: false,
graphEventId: '',
},
};
test('it mounts with correct tab highlighted', () => {
Expand Down Expand Up @@ -128,6 +129,7 @@ describe('Tab Navigation', () => {
[CONSTANTS.timeline]: {
id: '',
isOpen: false,
graphEventId: '',
},
};
test('it mounts with correct tab highlighted', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ export const makeMapStateToProps = () => {
? {
id: flyoutTimeline.savedObjectId != null ? flyoutTimeline.savedObjectId : '',
isOpen: flyoutTimeline.show,
graphEventId: flyoutTimeline.graphEventId ?? '',
}
: { id: '', isOpen: false };
: { id: '', isOpen: false, graphEventId: '' };

let searchAttr: {
[CONSTANTS.appQuery]?: Query;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const dispatchSetInitialStateFromUrl = (
queryTimelineById({
apolloClient,
duplicate: false,
graphEventId: timeline.graphEventId,
timelineId: timeline.id,
openTimeline: timeline.isOpen,
updateIsLoading: updateTimelineIsLoading,
Expand Down
Loading

0 comments on commit 384ff47

Please sign in to comment.