Skip to content

Commit

Permalink
[Security Solution][Case] Alerts comment UI (elastic#84450)
Browse files Browse the repository at this point in the history
Co-authored-by: Kibana Machine <[email protected]>
Co-authored-by: Xavier Mouligneau <[email protected]>
  • Loading branch information
3 people committed Dec 10, 2020
1 parent 0968676 commit 267ca1f
Show file tree
Hide file tree
Showing 15 changed files with 398 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export enum TimelineId {
detectionsPage = 'detections-page',
networkPageExternalAlerts = 'network-page-external-alerts',
active = 'timeline-1',
casePage = 'timeline-case',
test = 'test', // Reserved for testing purposes
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 { CommentType } from '../../../../../case/common/api';
import { Comment } from '../../containers/types';

export const getRuleIdsFromComments = (comments: Comment[]) =>
comments.reduce<string[]>((ruleIds, comment: Comment) => {
if (comment.type === CommentType.alert) {
return [...ruleIds, comment.alertId];
}

return ruleIds;
}, []);

export const buildAlertsQuery = (ruleIds: string[]) => ({
query: {
bool: {
filter: {
bool: {
should: ruleIds.map((_id) => ({ match: { _id } })),
minimum_should_match: 1,
},
},
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,32 @@ import { act, waitFor } from '@testing-library/react';

import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';

import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';

const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});

jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_user_actions');
jest.mock('../../containers/use_get_case');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../../../detections/containers/detection_engine/alerts/use_query');
jest.mock('../user_action_tree/user_action_timestamp');

const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
const useQueryAlertsMock = useQueryAlerts as jest.Mock;

export const caseProps: CaseProps = {
caseId: basicCase.id,
Expand Down Expand Up @@ -99,6 +110,10 @@ describe('CaseView ', () => {
useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions);
usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService }));
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, isLoading: false }));
useQueryAlertsMock.mockImplementation(() => ({
isLoading: false,
alerts: { hits: { hists: [] } },
}));
});

it('should render CaseComponent', async () => {
Expand Down Expand Up @@ -435,6 +450,7 @@ describe('CaseView ', () => {
).toBeTruthy();
});
});

// TO DO fix when the useEffects in edit_connector are cleaned up
it.skip('should revert to the initial connector in case of failure', async () => {
updateCaseProperty.mockImplementation(({ onError }) => {
Expand Down Expand Up @@ -486,6 +502,7 @@ describe('CaseView ', () => {
).toBe(connectorName);
});
});

// TO DO fix when the useEffects in edit_connector are cleaned up
it.skip('should update connector', async () => {
const wrapper = mount(
Expand Down Expand Up @@ -539,4 +556,27 @@ describe('CaseView ', () => {
},
});
});

it('it should create a new timeline on mount', async () => {
mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
</Router>
</TestProviders>
);

await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith({
type: 'x-pack/security_solution/local/timeline/CREATE_TIMELINE',
payload: {
columns: [],
expandedEvent: {},
id: 'timeline-case',
indexNames: [],
show: false,
},
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiLoadingSpinner,
EuiHorizontalRule,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';

import { CaseStatuses } from '../../../../../case/common/api';
import { Case, CaseConnector } from '../../containers/types';
Expand All @@ -40,6 +41,13 @@ import {
normalizeActionConnector,
getNoneConnector,
} from '../configure_cases/utils';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { buildAlertsQuery, getRuleIdsFromComments } from './helpers';
import { EventDetailsFlyout } from '../../../common/components/events_viewer/event_details_flyout';
import { useSourcererScope } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { TimelineId } from '../../../../common/types/timeline';
import { timelineActions } from '../../../timelines/store/timeline';
import { StatusActionButton } from '../status/button';

import * as i18n from './translations';
Expand Down Expand Up @@ -78,12 +86,34 @@ export interface CaseProps extends Props {
updateCase: (newCase: Case) => void;
}

interface Signal {
rule: {
id: string;
name: string;
};
}

interface SignalHit {
_id: string;
_index: string;
_source: {
signal: Signal;
};
}

export type Alert = {
_id: string;
_index: string;
} & Signal;

export const CaseComponent = React.memo<CaseProps>(
({ caseId, caseData, fetchCase, updateCase, userCanCrud }) => {
const dispatch = useDispatch();
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
const allCasesLink = getCaseUrl(search);
const caseDetailsLink = formatUrl(getCaseDetailsUrl({ id: caseId }), { absolute: true });
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);

const {
caseUserActions,
Expand All @@ -98,6 +128,39 @@ export const CaseComponent = React.memo<CaseProps>(
caseId,
});

const alertsQuery = useMemo(() => buildAlertsQuery(getRuleIdsFromComments(caseData.comments)), [
caseData.comments,
]);

/**
* For the future developer: useSourcererScope is security solution dependent.
* You can use useSignalIndex as an alternative.
*/
const { browserFields, docValueFields, selectedPatterns } = useSourcererScope(
SourcererScopeName.detections
);

const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts<SignalHit, unknown>(
alertsQuery,
selectedPatterns[0]
);

const alerts = useMemo(
() =>
alertsData?.hits.hits.reduce<Record<string, Alert>>(
(acc, { _id, _index, _source }) => ({
...acc,
[_id]: {
_id,
_index,
..._source.signal,
},
}),
{}
) ?? {},
[alertsData?.hits.hits]
);

// Update Fields
const onUpdateField = useCallback(
({ key, value, onSuccess, onError }: OnUpdateFields) => {
Expand Down Expand Up @@ -266,10 +329,10 @@ export const CaseComponent = React.memo<CaseProps>(
);

useEffect(() => {
if (initLoadingData && !isLoadingUserActions) {
if (initLoadingData && !isLoadingUserActions && !isLoadingAlerts) {
setInitLoadingData(false);
}
}, [initLoadingData, isLoadingUserActions]);
}, [initLoadingData, isLoadingAlerts, isLoadingUserActions]);

const backOptions = useMemo(
() => ({
Expand All @@ -281,6 +344,39 @@ export const CaseComponent = React.memo<CaseProps>(
[allCasesLink]
);

const showAlert = useCallback(
(alertId: string, index: string) => {
dispatch(
timelineActions.toggleExpandedEvent({
timelineId: TimelineId.casePage,
event: {
eventId: alertId,
indexName: index,
loading: false,
},
})
);
},
[dispatch]
);

// useEffect used for component's initialization
useEffect(() => {
if (init.current) {
init.current = false;
// We need to create a timeline to show the details view
dispatch(
timelineActions.createTimeline({
id: TimelineId.casePage,
columns: [],
indexNames: [],
expandedEvent: {},
show: false,
})
);
}
}, [dispatch]);

return (
<>
<HeaderWrapper>
Expand Down Expand Up @@ -327,6 +423,8 @@ export const CaseComponent = React.memo<CaseProps>(
onUpdateField={onUpdateField}
updateCase={updateCase}
userCanCrud={userCanCrud}
alerts={alerts}
onShowAlertDetails={showAlert}
/>
<MyEuiHorizontalRule margin="s" />
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
Expand Down Expand Up @@ -381,6 +479,11 @@ export const CaseComponent = React.memo<CaseProps>(
</EuiFlexGroup>
</MyWrapper>
</WhitePageWrapper>
<EventDetailsFlyout
browserFields={browserFields}
docValueFields={docValueFields}
timelineId={TimelineId.casePage}
/>
<SpyRoute state={spyState} pageName={SecurityPageName.case} />
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps } from '@elastic/eui';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps, EuiIconTip } from '@elastic/eui';

import {
CaseFullExternalService,
Expand All @@ -21,7 +21,10 @@ import { UserActionTimestamp } from './user_action_timestamp';
import { UserActionCopyLink } from './user_action_copy_link';
import { UserActionMoveToReference } from './user_action_move_to_reference';
import { Status, statuses } from '../status';
import * as i18n from '../case_view/translations';
import { UserActionShowAlert } from './user_action_show_alert';
import * as i18n from './translations';
import { Alert } from '../case_view';
import { AlertCommentEvent } from './user_action_alert_comment_event';

interface LabelTitle {
action: CaseUserActions;
Expand Down Expand Up @@ -182,3 +185,52 @@ export const getUpdateAction = ({
</EuiFlexGroup>
),
});

export const getAlertComment = ({
action,
alert,
onShowAlertDetails,
}: {
action: CaseUserActions;
alert: Alert | undefined;
onShowAlertDetails: (alertId: string, index: string) => void;
}): EuiCommentProps => {
return {
username: (
<UserActionUsernameWithAvatar
username={action.actionBy.username}
fullName={action.actionBy.fullName}
/>
),
className: 'comment-alert',
type: 'update',
event: <AlertCommentEvent alert={alert} />,
'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`,
timestamp: <UserActionTimestamp createdAt={action.actionAt} />,
timelineIcon: 'bell',
actions: (
<EuiFlexGroup>
<EuiFlexItem>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
<EuiFlexItem>
{alert != null ? (
<UserActionShowAlert
id={action.actionId}
alert={alert}
onShowAlertDetails={onShowAlertDetails}
/>
) : (
<EuiIconTip
aria-label={i18n.ALERT_NOT_FOUND_TOOLTIP}
size="l"
type="alert"
color="danger"
content={i18n.ALERT_NOT_FOUND_TOOLTIP}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
),
};
};
Loading

0 comments on commit 267ca1f

Please sign in to comment.