[7.x] [Security Solution][Case] Fix comment content when pushing alerts to external services (#86812) (#87441)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b6237b1cf1
commit
938b993986
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue