[Security Solution][Case] Alerts comment UI (#84450)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2020-12-10 16:17:47 +02:00 committed by GitHub
parent 44688d9595
commit a740a3f8ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 398 additions and 22 deletions

View file

@ -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
}

View file

@ -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,
},
},
},
},
});

View file

@ -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,
@ -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 () => {
@ -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 }) => {
@ -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(
@ -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,
},
});
});
});
});

View file

@ -4,6 +4,10 @@
* 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,
@ -11,9 +15,6 @@ import {
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';
@ -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';
@ -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,
@ -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) => {
@ -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(
() => ({
@ -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>
@ -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">
@ -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} />
</>
);

View file

@ -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,
@ -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;
@ -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>
),
};
};

View file

@ -18,6 +18,8 @@ import { TestProviders } from '../../../common/mock';
const fetchUserActions = jest.fn();
const onUpdateField = jest.fn();
const updateCase = jest.fn();
const onShowAlertDetails = jest.fn();
const defaultProps = {
caseServices: {},
caseUserActions: [],
@ -29,6 +31,8 @@ const defaultProps = {
onUpdateField,
updateCase,
userCanCrud: true,
alerts: {},
onShowAlertDetails,
};
const useUpdateCommentMock = useUpdateComment as jest.Mock;
jest.mock('../../containers/use_update_comment');

View file

@ -22,16 +22,17 @@ import { Case, CaseUserActions } from '../../containers/types';
import { useUpdateComment } from '../../containers/use_update_comment';
import { useCurrentUser } from '../../../common/lib/kibana';
import { AddComment, AddCommentRefObject } from '../add_comment';
import { ActionConnector } from '../../../../../case/common/api/cases';
import { ActionConnector, CommentType } from '../../../../../case/common/api/cases';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { parseString } from '../../containers/utils';
import { OnUpdateFields } from '../case_view';
import { Alert, OnUpdateFields } from '../case_view';
import {
getConnectorLabelTitle,
getLabelTitle,
getPushedServiceLabelTitle,
getPushInfo,
getUpdateAction,
getAlertComment,
} from './helpers';
import { UserActionAvatar } from './user_action_avatar';
import { UserActionMarkdown } from './user_action_markdown';
@ -50,6 +51,8 @@ export interface UserActionTreeProps {
onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void;
updateCase: (newCase: Case) => void;
userCanCrud: boolean;
alerts: Record<string, Alert>;
onShowAlertDetails: (alertId: string, index: string) => void;
}
const MyEuiFlexGroup = styled(EuiFlexGroup)`
@ -78,6 +81,17 @@ const MyEuiCommentList = styled(EuiCommentList)`
display: none;
}
}
& .comment-alert .euiCommentEvent {
background-color: ${theme.eui.euiColorLightestShade};
border: ${theme.eui.euiFlyoutBorder};
padding: 10px;
border-radius: ${theme.eui.paddingSizes.xs};
}
& .comment-alert .euiCommentEvent__headerData {
flex-grow: 1;
}
`}
`;
@ -96,6 +110,8 @@ export const UserActionTree = React.memo(
onUpdateField,
updateCase,
userCanCrud,
alerts,
onShowAlertDetails,
}: UserActionTreeProps) => {
const { commentId } = useParams<{ commentId?: string }>();
const handlerTimeoutId = useRef(0);
@ -105,6 +121,7 @@ export const UserActionTree = React.memo(
const { isLoadingIds, patchComment } = useUpdateComment();
const currentUser = useCurrentUser();
const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]);
const handleManageMarkdownEditId = useCallback(
(id: string) => {
if (!manageMarkdownEditIds.includes(id)) {
@ -264,7 +281,7 @@ export const UserActionTree = React.memo(
// Comment creation
if (action.commentId != null && action.action === 'create') {
const comment = caseData.comments.find((c) => c.id === action.commentId);
if (comment != null) {
if (comment != null && comment.type === CommentType.user) {
return [
...comments,
{
@ -316,6 +333,9 @@ export const UserActionTree = React.memo(
),
},
];
} else if (comment != null && comment.type === CommentType.alert) {
const alert = alerts[comment.alertId];
return [...comments, getAlertComment({ action, alert, onShowAlertDetails })];
}
}
@ -380,7 +400,7 @@ export const UserActionTree = React.memo(
];
}
// title, description, comments, tags, status
// title, description, comment updates, tags
if (
action.actionField.length === 1 &&
['title', 'description', 'comment', 'tags', 'status'].includes(action.actionField[0])
@ -412,6 +432,8 @@ export const UserActionTree = React.memo(
manageMarkdownEditIds,
selectedOutlineCommentId,
userCanCrud,
alerts,
onShowAlertDetails,
]
);

View file

@ -33,3 +33,31 @@ export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate(
defaultMessage: 'Highlight the referenced comment',
}
);
export const ALERT_COMMENT_LABEL_TITLE = i18n.translate(
'xpack.securitySolution.case.caseView.alertCommentLabelTitle',
{
defaultMessage: 'added an alert from',
}
);
export const ALERT_RULE_DELETED_COMMENT_LABEL = i18n.translate(
'xpack.securitySolution.case.caseView.alertRuleDeletedLabelTitle',
{
defaultMessage: 'added an alert',
}
);
export const SHOW_ALERT_TOOLTIP = i18n.translate(
'xpack.securitySolution.case.caseView.showAlertTooltip',
{
defaultMessage: 'Show alert details',
}
);
export const ALERT_NOT_FOUND_TOOLTIP = i18n.translate(
'xpack.securitySolution.case.caseView.showAlertDeletedTooltip',
{
defaultMessage: 'Alert not found',
}
);

View file

@ -0,0 +1,48 @@
/*
* 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 React, { memo, useCallback } from 'react';
import { EuiLink } from '@elastic/eui';
import { APP_ID } from '../../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to';
import { SecurityPageName } from '../../../app/types';
import { Alert } from '../case_view';
import * as i18n from './translations';
interface Props {
alert: Alert | undefined;
}
const AlertCommentEventComponent: React.FC<Props> = ({ alert }) => {
const ruleName = alert?.rule?.name ?? null;
const ruleId = alert?.rule?.id ?? null;
const { navigateToApp } = useKibana().services.application;
const { formatUrl } = useFormatUrl(SecurityPageName.detections);
const onLinkClick = useCallback(
(ev: { preventDefault: () => void }) => {
ev.preventDefault();
navigateToApp(`${APP_ID}:${SecurityPageName.detections}`, {
path: formatUrl(getRuleDetailsUrl(ruleId ?? '')),
});
},
[ruleId, formatUrl, navigateToApp]
);
return ruleId != null && ruleName != null ? (
<>
{`${i18n.ALERT_COMMENT_LABEL_TITLE} `}
<EuiLink onClick={onLinkClick}>{ruleName}</EuiLink>
</>
) : (
<>{i18n.ALERT_RULE_DELETED_COMMENT_LABEL}</>
);
};
export const AlertCommentEvent = memo(AlertCommentEventComponent);

View file

@ -0,0 +1,49 @@
/*
* 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 React, { memo, useCallback } from 'react';
import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import { Alert } from '../case_view';
import * as i18n from './translations';
interface UserActionShowAlertProps {
id: string;
alert: Alert;
onShowAlertDetails: (alertId: string, index: string) => void;
}
const UserActionShowAlertComponent = ({
id,
alert,
onShowAlertDetails,
}: UserActionShowAlertProps) => {
const onClick = useCallback(() => onShowAlertDetails(alert._id, alert._index), [
alert._id,
alert._index,
onShowAlertDetails,
]);
return (
<EuiToolTip position="top" content={<p>{i18n.SHOW_ALERT_TOOLTIP}</p>}>
<EuiButtonIcon
aria-label={i18n.SHOW_ALERT_TOOLTIP}
data-test-subj={`comment-action-show-alert-${id}`}
onClick={onClick}
iconType="arrowRight"
id={`${id}-show-alert`}
/>
</EuiToolTip>
);
};
export const UserActionShowAlert = memo(
UserActionShowAlertComponent,
(prevProps, nextProps) =>
prevProps.id === nextProps.id &&
deepEqual(prevProps.alert, nextProps.alert) &&
prevProps.onShowAlertDetails === nextProps.onShowAlertDetails
);

View file

@ -191,8 +191,8 @@ export const elasticUserSnake = {
email: 'leslie.knope@elastic.co',
};
export const basicCommentSnake: CommentResponse = {
...basicComment,
comment: 'Solve this fast!',
type: CommentType.user,
id: basicCommentId,
created_at: basicCreatedAt,
created_by: elasticUserSnake,
@ -200,6 +200,7 @@ export const basicCommentSnake: CommentResponse = {
pushed_by: null,
updated_at: null,
updated_by: null,
version: 'WzQ3LDFc',
};
export const basicCaseSnake: CaseResponse = {

View file

@ -9,24 +9,22 @@ import {
UserActionField,
UserAction,
CaseConnector,
CommentType,
CommentRequest,
CaseStatuses,
} from '../../../../case/common/api';
export { CaseConnector, ActionConnector } from '../../../../case/common/api';
export interface Comment {
export type Comment = CommentRequest & {
id: string;
createdAt: string;
createdBy: ElasticUser;
comment: string;
type: CommentType.user;
pushedAt: string | null;
pushedBy: string | null;
updatedAt: string | null;
updatedBy: ElasticUser | null;
version: string;
}
};
export interface CaseUserActions {
actionId: string;
actionField: UserActionField;

View file

@ -20,7 +20,7 @@ import {
} from './mock';
import * as api from './api';
import { CaseServices } from './use_get_case_user_actions';
import { CaseConnector, ConnectorTypes } from '../../../../case/common/api/connectors';
import { CaseConnector, ConnectorTypes, CommentType } from '../../../../case/common/api';
jest.mock('./api');
@ -53,7 +53,7 @@ describe('usePostPushToService', () => {
comments: [
{
commentId: basicComment.id,
comment: basicComment.comment,
comment: basicComment.type === CommentType.user ? basicComment.comment : '',
createdAt: basicComment.createdAt,
createdBy: serviceConnectorUser,
updatedAt: null,

View file

@ -10,6 +10,7 @@ import {
ServiceConnectorCaseResponse,
ServiceConnectorCaseParams,
CaseConnector,
CommentType,
} from '../../../../case/common/api';
import {
errorToToaster,
@ -177,7 +178,7 @@ export const formatServiceRequestData = (
)
.map((c) => ({
commentId: c.id,
comment: c.comment,
comment: c.type === CommentType.user ? c.comment : '',
createdAt: c.createdAt,
createdBy: {
fullName: c.createdBy.fullName ?? null,

View file

@ -12,7 +12,7 @@ import { navTabs } from '../../../app/home/home_navigations';
import { APP_ID } from '../../../../common/constants';
import { useKibana } from '../../lib/kibana';
export { getDetectionEngineUrl } from './redirect_to_detection_engine';
export { getDetectionEngineUrl, getRuleDetailsUrl } from './redirect_to_detection_engine';
export { getAppOverviewUrl } from './redirect_to_overview';
export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts';
export { getNetworkUrl, getNetworkDetailsUrl } from './redirect_to_network';