[Security Solution][Case] Fix comment content when pushing alerts to external services (#86812)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2021-01-06 11:40:40 +02:00 committed by GitHub
parent e63e3d869d
commit 34a3982f3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 212 additions and 19 deletions

View file

@ -90,6 +90,8 @@ interface Signal {
rule: {
id: string;
name: string;
to: string;
from: string;
};
}
@ -97,6 +99,7 @@ interface SignalHit {
_id: string;
_index: string;
_source: {
'@timestamp': string;
signal: Signal;
};
}
@ -104,6 +107,7 @@ interface SignalHit {
export type Alert = {
_id: string;
_index: string;
'@timestamp': string;
} & Signal;
export const CaseComponent = React.memo<CaseProps>(
@ -153,6 +157,7 @@ export const CaseComponent = React.memo<CaseProps>(
[_id]: {
_id,
_index,
'@timestamp': _source['@timestamp'],
..._source.signal,
},
}),
@ -291,6 +296,7 @@ export const CaseComponent = React.memo<CaseProps>(
updateCase: handleUpdateCase,
userCanCrud,
isValidConnector,
alerts,
});
const onSubmitConnector = useCallback(

View file

@ -42,6 +42,7 @@ describe('usePushToService', () => {
isLoading: false,
postPushToService,
};
const mockConnector = connectorsMock[0];
const actionLicense = actionLicenses[0];
const caseServices = {
@ -53,6 +54,7 @@ describe('usePushToService', () => {
hasDataToPush: true,
},
};
const defaultArgs = {
connector: {
id: mockConnector.id,
@ -67,6 +69,19 @@ describe('usePushToService', () => {
updateCase,
userCanCrud: true,
isValidConnector: true,
alerts: {
'alert-id-1': {
_id: 'alert-id-1',
_index: 'alert-index-1',
'@timestamp': '2020-11-20T15:35:28.373Z',
rule: {
id: 'rule-id-1',
name: 'Awesome rule',
from: 'now-360s',
to: 'now',
},
},
},
};
beforeEach(() => {
@ -98,6 +113,19 @@ describe('usePushToService', () => {
type: ConnectorTypes.servicenow,
},
updateCase,
alerts: {
'alert-id-1': {
_id: 'alert-id-1',
_index: 'alert-index-1',
'@timestamp': '2020-11-20T15:35:28.373Z',
rule: {
id: 'rule-id-1',
name: 'Awesome rule',
from: 'now-360s',
to: 'now',
},
},
},
});
expect(result.current.pushCallouts).toBeNull();
});

View file

@ -21,6 +21,7 @@ import { CaseServices } from '../../containers/use_get_case_user_actions';
import { LinkAnchor } from '../../../common/components/links';
import { SecurityPageName } from '../../../app/types';
import { ErrorMessage } from '../callout/types';
import { Alert } from '../case_view';
export interface UsePushToService {
caseId: string;
@ -31,6 +32,7 @@ export interface UsePushToService {
updateCase: (newCase: Case) => void;
userCanCrud: boolean;
isValidConnector: boolean;
alerts: Record<string, Alert>;
}
export interface ReturnUsePushToService {
@ -47,6 +49,7 @@ export const usePushToService = ({
updateCase,
userCanCrud,
isValidConnector,
alerts,
}: UsePushToService): ReturnUsePushToService => {
const history = useHistory();
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case);
@ -61,9 +64,10 @@ export const usePushToService = ({
caseServices,
connector,
updateCase,
alerts,
});
}
}, [caseId, caseServices, connector, postPushToService, updateCase]);
}, [alerts, caseId, caseServices, connector, postPushToService, updateCase]);
const goToConfigureCases = useCallback(
(ev) => {

View file

@ -6,6 +6,8 @@
import { i18n } from '@kbn/i18n';
export * from '../translations';
export const ERROR_TITLE = i18n.translate('xpack.securitySolution.containers.case.errorTitle', {
defaultMessage: 'Error fetching data',
});

View file

@ -23,10 +23,20 @@ import { CaseServices } from './use_get_case_user_actions';
import { CaseConnector, ConnectorTypes, CommentType } from '../../../../case/common/api';
jest.mock('./api');
jest.mock('../../common/components/link_to', () => {
const originalModule = jest.requireActual('../../common/components/link_to');
return {
...originalModule,
getTimelineTabsUrl: jest.fn(),
useFormatUrl: jest.fn().mockReturnValue({ formatUrl: jest.fn(), search: 'urlSearch' }),
};
});
describe('usePostPushToService', () => {
const abortCtrl = new AbortController();
const updateCase = jest.fn();
const formatUrl = jest.fn();
const samplePush = {
caseId: pushedCase.id,
caseServices: {
@ -45,7 +55,21 @@ describe('usePostPushToService', () => {
fields: { issueType: 'Task', priority: 'Low', parent: null },
} as CaseConnector,
updateCase,
alerts: {
'alert-id-1': {
_id: 'alert-id-1',
_index: 'alert-index-1',
'@timestamp': '2020-11-20T15:35:28.373Z',
rule: {
id: 'rule-id-1',
name: 'Awesome rule',
from: 'now-360s',
to: 'now',
},
},
},
};
const sampleServiceRequestData = {
savedObjectId: pushedCase.id,
createdAt: pushedCase.createdAt,
@ -142,11 +166,13 @@ describe('usePostPushToService', () => {
expect(spyOnPushToService).toBeCalledWith(
samplePush.connector.id,
samplePush.connector.type,
formatServiceRequestData(
basicCase,
samplePush.connector,
sampleCaseServices as CaseServices
),
formatServiceRequestData({
myCase: basicCase,
connector: samplePush.connector,
caseServices: sampleCaseServices as CaseServices,
alerts: samplePush.alerts,
formatUrl,
}),
abortCtrl.signal
);
});
@ -162,6 +188,7 @@ describe('usePostPushToService', () => {
type: ConnectorTypes.none,
fields: null,
},
alerts: samplePush.alerts,
updateCase,
};
const spyOnPushToService = jest.spyOn(api, 'pushToService');
@ -176,7 +203,13 @@ describe('usePostPushToService', () => {
expect(spyOnPushToService).toBeCalledWith(
samplePush2.connector.id,
samplePush2.connector.type,
formatServiceRequestData(basicCase, samplePush2.connector, {}),
formatServiceRequestData({
myCase: basicCase,
connector: samplePush2.connector,
caseServices: {},
alerts: samplePush.alerts,
formatUrl,
}),
abortCtrl.signal
);
});
@ -213,7 +246,13 @@ describe('usePostPushToService', () => {
it('formatServiceRequestData - current connector', () => {
const caseServices = sampleCaseServices;
const result = formatServiceRequestData(pushedCase, samplePush.connector, caseServices);
const result = formatServiceRequestData({
myCase: pushedCase,
connector: samplePush.connector,
caseServices,
alerts: samplePush.alerts,
formatUrl,
});
expect(result).toEqual(sampleServiceRequestData);
});
@ -225,7 +264,13 @@ describe('usePostPushToService', () => {
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: 'RJ-01' },
};
const result = formatServiceRequestData(pushedCase, connector as CaseConnector, caseServices);
const result = formatServiceRequestData({
myCase: pushedCase,
connector: connector as CaseConnector,
caseServices,
alerts: samplePush.alerts,
formatUrl,
});
expect(result).toEqual({
...sampleServiceRequestData,
...connector.fields,
@ -237,13 +282,22 @@ describe('usePostPushToService', () => {
const caseServices = {
'123': sampleCaseServices['123'],
};
const connector = {
id: '456',
name: 'connector 2',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: null },
};
const result = formatServiceRequestData(pushedCase, connector as CaseConnector, caseServices);
const result = formatServiceRequestData({
myCase: pushedCase,
connector: connector as CaseConnector,
caseServices,
alerts: samplePush.alerts,
formatUrl,
});
expect(result).toEqual({
...sampleServiceRequestData,
...connector.fields,
@ -251,6 +305,32 @@ describe('usePostPushToService', () => {
});
});
it('formatServiceRequestData - Alert comment content', () => {
formatUrl.mockReturnValue('https://app.com/detections');
const caseServices = sampleCaseServices;
const result = formatServiceRequestData({
myCase: {
...pushedCase,
comments: [
{
...pushedCase.comments[0],
type: CommentType.alert,
alertId: 'alert-id-1',
index: 'alert-index-1',
},
],
},
connector: samplePush.connector,
caseServices,
alerts: samplePush.alerts,
formatUrl,
});
expect(result.comments![0].comment).toEqual(
'[Alert](https://app.com/detections?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:alert-id-1),type:phrase),query:(match:(_id:(query:alert-id-1,type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%272020-11-20T15:29:28.373Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)),timeline:(linkTo:!(global),timerange:(from:%272020-11-20T15:29:28.373Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)))) added to case.'
);
});
it('unhappy path', async () => {
const spyOnPushToService = jest.spyOn(api, 'pushToService');
spyOnPushToService.mockImplementation(() => {

View file

@ -5,6 +5,8 @@
*/
import { useReducer, useCallback } from 'react';
import moment from 'moment';
import dateMath from '@elastic/datemath';
import {
ServiceConnectorCaseResponse,
@ -12,15 +14,18 @@ import {
CaseConnector,
CommentType,
} from '../../../../case/common/api';
import { SecurityPageName } from '../../app/types';
import { useFormatUrl, FormatUrl, getRuleDetailsUrl } from '../../common/components/link_to';
import {
errorToToaster,
useStateToaster,
displaySuccessToast,
} from '../../common/components/toasters';
import { Alert } from '../components/case_view';
import { getCase, pushToService, pushCase } from './api';
import * as i18n from './translations';
import { Case } from './types';
import { Case, Comment } from './types';
import { CaseServices } from './use_get_case_user_actions';
interface PushToServiceState {
@ -72,6 +77,7 @@ interface PushToServiceRequest {
caseId: string;
connector: CaseConnector;
caseServices: CaseServices;
alerts: Record<string, Alert>;
updateCase: (newCase: Case) => void;
}
@ -80,6 +86,7 @@ export interface UsePostPushToService extends PushToServiceState {
caseId,
caseServices,
connector,
alerts,
updateCase,
}: PushToServiceRequest) => void;
}
@ -92,9 +99,10 @@ export const usePostPushToService = (): UsePostPushToService => {
isError: false,
});
const [, dispatchToaster] = useStateToaster();
const { formatUrl } = useFormatUrl(SecurityPageName.detections);
const postPushToService = useCallback(
async ({ caseId, caseServices, connector, updateCase }: PushToServiceRequest) => {
async ({ caseId, caseServices, connector, alerts, updateCase }: PushToServiceRequest) => {
let cancel = false;
const abortCtrl = new AbortController();
try {
@ -103,7 +111,13 @@ export const usePostPushToService = (): UsePostPushToService => {
const responseService = await pushToService(
connector.id,
connector.type,
formatServiceRequestData(casePushData, connector, caseServices),
formatServiceRequestData({
myCase: casePushData,
connector,
caseServices,
alerts,
formatUrl,
}),
abortCtrl.signal
);
const responseCase = await pushCase(
@ -148,11 +162,59 @@ export const usePostPushToService = (): UsePostPushToService => {
return { ...state, postPushToService };
};
export const formatServiceRequestData = (
myCase: Case,
connector: CaseConnector,
caseServices: CaseServices
): ServiceConnectorCaseParams => {
export const determineToAndFrom = (alert: Alert) => {
const ellapsedTimeRule = moment.duration(
moment().diff(dateMath.parse(alert.rule?.from != null ? alert.rule.from : 'now-0s'))
);
const from = moment(alert['@timestamp'] ?? new Date())
.subtract(ellapsedTimeRule)
.toISOString();
const to = moment(alert['@timestamp'] ?? new Date()).toISOString();
return { to, from };
};
const getAlertFilterUrl = (alert: Alert): string => {
const { to, from } = determineToAndFrom(alert);
return `?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:${alert._id}),type:phrase),query:(match:(_id:(query:${alert._id},type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)),timeline:(linkTo:!(global),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)))`;
};
const getCommentContent = (
comment: Comment,
alerts: Record<string, Alert>,
formatUrl: FormatUrl
): string => {
if (comment.type === CommentType.user) {
return comment.comment;
} else if (comment.type === CommentType.alert) {
const alert = alerts[comment.alertId];
const ruleDetailsLink = formatUrl(getRuleDetailsUrl(alert.rule.id), {
absolute: true,
skipSearch: true,
});
return `[${i18n.ALERT}](${ruleDetailsLink}${getAlertFilterUrl(alert)}) ${
i18n.ALERT_ADDED_TO_CASE
}.`;
}
return '';
};
export const formatServiceRequestData = ({
myCase,
connector,
caseServices,
alerts,
formatUrl,
}: {
myCase: Case;
connector: CaseConnector;
caseServices: CaseServices;
alerts: Record<string, Alert>;
formatUrl: FormatUrl;
}): ServiceConnectorCaseParams => {
const {
id: caseId,
createdAt,
@ -179,7 +241,7 @@ export const formatServiceRequestData = (
)
.map((c) => ({
commentId: c.id,
comment: c.type === CommentType.user ? c.comment : '',
comment: getCommentContent(c, alerts, formatUrl),
createdAt: c.createdAt,
createdBy: {
fullName: c.createdBy.fullName ?? null,

View file

@ -278,3 +278,14 @@ export const SYNC_ALERTS_HELP = i18n.translate(
'Enabling this option will sync the status of alerts in this case with the case status.',
}
);
export const ALERT = i18n.translate('xpack.securitySolution.common.alertLabel', {
defaultMessage: 'Alert',
});
export const ALERT_ADDED_TO_CASE = i18n.translate(
'xpack.securitySolution.common.alertAddedToCase',
{
defaultMessage: 'added to case',
}
);