[Security Solution] Detection rules for case UI (#91434)

* Adding type field to client

* Removing context and adding association type

* Handle alerts from multiple indices

* Adding flow for adding a sub case

* Making progress on creating alerts from rules

* Refactored add comment to handle case and sub case

* Starting sub case API and refactoring of case client

* Fleshing out find cases

* Finished the find cases api

* Filtering comments by association type

* Fixing tests and types

* Updating snapshots

* Cleaning up comment references

* Working unit tests

* Fixing integration tests and got ES to work

* Unit tests and api integration test working

* Refactoring find and get_status

* Starting patch, and update

* script for sub cases

* Removing converted_by and fixing type errors

* Adding docs for script

* Removing converted_by and fixing integration test

* init expanded rows

* Adding sub case id to comment routes

* Removing stringify comparison

* styling

* clean up

* add status column

* styling

* hide actions if it has sub-cases

* Adding delete api and tests

* generated alert

* Updating license

* missed license files

* Integration tests passing

* Adding more tests for sub cases

* wip

* Find int tests, scoped client, patch sub user actions

* fixing types and call cluster

* fixing get sub case param issue

* Adding user actions for sub cases

* Preventing alerts on collections and refactoring user

* Allowing type to be updated for ind cases

* subcases attached to api

* combine enum on UI for simplification

* Refactoring and writing tests

* Fixing sub case status filtering

* add alerts count

* Adding more tests not allowing gen alerts patch

* Working unit tests

* Push to connector gets all sub case comments

* Writing more tests and cleaning up

* Updating push functionality for generated alerts and sub cases

* Adding comment about updating collection sync

* use CaseType to check if it is a sub-case

* fix types and disable selection if it has subcases

* isEmpty

* Detection rule correctly adding alerts to sub case

* update api and functionality to accept sub case

* integration part I

* fix integration with case connector

* Fix manual attach

* Fix types

* Fix bug when updating

* Fix bug with user actions

* Fix react key error

* Fix bug when pushing a lot of alerts

* fix lint error

* Fix limit

* fix title on sub case

* fix unit tests

* rm bazel

* fix unit tests and cypress test

* enable delete case icon

* revert change

* review

* Fix the scripts alerts generation code

* temp work

* Fix rule types and add migration

* fix types

* fix types error

* Remove query alerts

* Fix rules

* fix types

* fix lint error

* fix types

* delete a sub case

* rm unused i18n

* fix delete cases

* fix unit tests

* fix unit test

* update Case type

* fix types

* fix unit test

* final integration between rule and case

* fix integration test

* fix unit test + bring back connector in action of rule

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
This commit is contained in:
Angela Chuang 2021-02-18 01:32:40 +00:00 committed by GitHub
parent 9180ed112c
commit 97d391a636
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
95 changed files with 1917 additions and 542 deletions

View file

@ -123,6 +123,7 @@ export const CaseResponseRt = rt.intersection([
version: rt.string,
}),
rt.partial({
subCaseIds: rt.array(rt.string),
subCases: rt.array(SubCaseResponseRt),
comments: rt.array(CommentResponseRt),
}),

View file

@ -52,7 +52,11 @@ export const ContextTypeUserRt = rt.type({
export const AlertCommentRequestRt = rt.type({
type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]),
alertId: rt.union([rt.array(rt.string), rt.string]),
index: rt.string,
index: rt.union([rt.array(rt.string), rt.string]),
rule: rt.type({
id: rt.union([rt.string, rt.null]),
name: rt.union([rt.string, rt.null]),
}),
});
const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]);
@ -108,6 +112,7 @@ export const CommentsResponseRt = rt.type({
export const AllCommentsResponseRt = rt.array(CommentResponseRt);
export type AttributesTypeAlerts = rt.TypeOf<typeof AttributesTypeAlertsRt>;
export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>;
export type CommentRequest = rt.TypeOf<typeof CommentRequestRt>;
export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>;

View file

@ -49,6 +49,7 @@ const CaseUserActionResponseRT = rt.intersection([
case_id: rt.string,
comment_id: rt.union([rt.string, rt.null]),
}),
rt.partial({ sub_case_id: rt.string }),
]);
export const CaseUserActionAttributesRt = CaseUserActionBasicRT;

View file

@ -13,6 +13,7 @@ import {
SUB_CASE_DETAILS_URL,
SUB_CASES_URL,
CASE_PUSH_URL,
SUB_CASE_USER_ACTIONS_URL,
} from '../constants';
export const getCaseDetailsUrl = (id: string): string => {
@ -38,6 +39,11 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str
export const getCaseUserActionUrl = (id: string): string => {
return CASE_USER_ACTIONS_URL.replace('{case_id}', id);
};
export const getSubCaseUserActionUrl = (caseID: string, subCaseID: string): string => {
return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID);
};
export const getCasePushUrl = (caseId: string, connectorId: string): string => {
return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId);
};

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { DEFAULT_MAX_SIGNALS } from '../../security_solution/common/constants';
export const APP_ID = 'case';
/**
@ -19,6 +21,7 @@ export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`;
export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`;
export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`;
export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`;
export const SUB_CASE_USER_ACTIONS_URL = `${SUB_CASE_DETAILS_URL}/user_actions`;
export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`;
export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`;
@ -45,3 +48,10 @@ export const SUPPORTED_CONNECTORS = [
JIRA_ACTION_TYPE_ID,
RESILIENT_ACTION_TYPE_ID,
];
/**
* Alerts
*/
export const MAX_ALERTS_PER_SUB_CASE = 5000;
export const MAX_GENERATED_ALERTS_PER_SUB_CASE = MAX_ALERTS_PER_SUB_CASE / DEFAULT_MAX_SIGNALS;

View file

@ -76,6 +76,7 @@ describe('create', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",
@ -174,6 +175,7 @@ describe('create', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",
@ -239,6 +241,7 @@ describe('create', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",

View file

@ -26,19 +26,24 @@ export const get = async ({
includeComments = false,
includeSubCaseComments = false,
}: GetParams): Promise<CaseResponse> => {
const theCase = await caseService.getCase({
client: savedObjectsClient,
id,
});
const [theCase, subCasesForCaseId] = await Promise.all([
caseService.getCase({
client: savedObjectsClient,
id,
}),
caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }),
]);
const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id);
if (!includeComments) {
return CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: theCase,
subCaseIds,
})
);
}
const theComments = await caseService.getAllCaseComments({
client: savedObjectsClient,
id,
@ -53,6 +58,7 @@ export const get = async ({
flattenCaseSavedObject({
savedObject: theCase,
comments: theComments.saved_objects,
subCaseIds,
totalComment: theComments.total,
totalAlerts: countAlertsForID({ comments: theComments, id }),
})

View file

@ -54,6 +54,10 @@ export const commentAlert: CommentResponse = {
id: 'mock-comment-1',
alertId: 'alert-id-1',
index: 'alert-index-1',
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
type: CommentType.alert as const,
created_at: '2019-11-25T21:55:00.177Z',
created_by: {

View file

@ -71,6 +71,7 @@ describe('update', () => {
"syncAlerts": true,
},
"status": "closed",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",
@ -166,6 +167,7 @@ describe('update', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",
@ -233,6 +235,7 @@ describe('update', () => {
"syncAlerts": true,
},
"status": "in-progress",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",
@ -300,6 +303,7 @@ describe('update', () => {
"syncAlerts": true,
},
"status": "closed",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",
@ -371,6 +375,7 @@ describe('update', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",

View file

@ -314,6 +314,7 @@ export const getCommentContextFromAttributes = (
type: attributes.type,
alertId: attributes.alertId,
index: attributes.index,
rule: attributes.rule,
};
default:
return {

View file

@ -75,6 +75,10 @@ describe('addComment', () => {
type: CommentType.alert,
alertId: 'test-id',
index: 'test-index',
rule: {
id: 'test-rule1',
name: 'test-rule',
},
},
});
@ -94,6 +98,10 @@ describe('addComment', () => {
"index": "test-index",
"pushed_at": null,
"pushed_by": null,
"rule": Object {
"id": "test-rule1",
"name": "test-rule",
},
"type": "alert",
"updated_at": null,
"updated_by": null,
@ -231,6 +239,10 @@ describe('addComment', () => {
type: CommentType.alert,
alertId: 'test-alert',
index: 'test-index',
rule: {
id: 'test-rule1',
name: 'test-rule',
},
},
});
@ -265,6 +277,10 @@ describe('addComment', () => {
type: CommentType.alert,
alertId: 'test-alert',
index: 'test-index',
rule: {
id: 'test-rule1',
name: 'test-rule',
},
},
});
@ -406,6 +422,10 @@ describe('addComment', () => {
type: CommentType.alert,
index: 'test-index',
alertId: 'test-id',
rule: {
id: 'test-rule1',
name: 'test-rule',
},
},
})
.catch((e) => {
@ -478,6 +498,10 @@ describe('addComment', () => {
type: CommentType.alert,
alertId: 'test-alert',
index: 'test-index',
rule: {
id: 'test-rule1',
name: 'test-rule',
},
},
})
.catch((e) => {

View file

@ -38,6 +38,8 @@ import {
import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services';
import { CommentableCase } from '../../common';
import { CaseClientHandler } from '..';
import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types';
import { MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common/constants';
async function getSubCase({
caseService,
@ -56,7 +58,20 @@ async function getSubCase({
}): Promise<SavedObject<SubCaseAttributes>> {
const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId);
if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) {
return mostRecentSubCase;
const subCaseAlertsAttachement = await caseService.getAllSubCaseComments({
client: savedObjectsClient,
id: mostRecentSubCase.id,
options: {
fields: [],
filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`,
page: 1,
perPage: 1,
},
});
if (subCaseAlertsAttachement.total <= MAX_GENERATED_ALERTS_PER_SUB_CASE) {
return mostRecentSubCase;
}
}
const newSubCase = await caseService.createSubCase({
@ -160,7 +175,11 @@ const addGeneratedAlerts = async ({
await caseClient.updateAlertsStatus({
ids,
status: subCase.attributes.status,
indices: new Set([newComment.attributes.index]),
indices: new Set([
...(Array.isArray(newComment.attributes.index)
? newComment.attributes.index
: [newComment.attributes.index]),
]),
});
}
@ -282,7 +301,11 @@ export const addComment = async ({
await caseClient.updateAlertsStatus({
ids,
status: updatedCase.status,
indices: new Set([newComment.attributes.index]),
indices: new Set([
...(Array.isArray(newComment.attributes.index)
? newComment.attributes.index
: [newComment.attributes.index]),
]),
});
}

View file

@ -59,6 +59,7 @@ export interface CaseClientGetAlerts {
export interface CaseClientGetUserActions {
caseId: string;
subCaseId?: string;
}
export interface MappingsClient {

View file

@ -6,7 +6,11 @@
*/
import { SavedObjectsClientContract } from 'kibana/server';
import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types';
import {
CASE_SAVED_OBJECT,
CASE_COMMENT_SAVED_OBJECT,
SUB_CASE_SAVED_OBJECT,
} from '../../saved_object_types';
import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api';
import { CaseUserActionServiceSetup } from '../../services';
@ -14,24 +18,36 @@ interface GetParams {
savedObjectsClient: SavedObjectsClientContract;
userActionService: CaseUserActionServiceSetup;
caseId: string;
subCaseId?: string;
}
export const get = async ({
savedObjectsClient,
userActionService,
caseId,
subCaseId,
}: GetParams): Promise<CaseUserActionsResponse> => {
const userActions = await userActionService.getUserActions({
client: savedObjectsClient,
caseId,
subCaseId,
});
return CaseUserActionsResponseRt.encode(
userActions.saved_objects.map((ua) => ({
...ua.attributes,
action_id: ua.id,
case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '',
comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null,
}))
userActions.saved_objects.reduce<CaseUserActionsResponse>((acc, ua) => {
if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) {
return acc;
}
return [
...acc,
{
...ua.attributes,
action_id: ua.id,
case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '',
comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null,
sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '',
},
];
}, [])
);
};

View file

@ -99,6 +99,10 @@ describe('common utils', () => {
alertId: ['a', 'b', 'c'],
index: '',
type: CommentType.generatedAlert,
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
},
],
},
@ -118,6 +122,10 @@ describe('common utils', () => {
alertId: ['a', 'b', 'c'],
index: '',
type: CommentType.alert,
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
},
],
},
@ -139,6 +147,10 @@ describe('common utils', () => {
alertId: ['a', 'b'],
index: '',
type: CommentType.alert,
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
},
{
comment: '',
@ -164,6 +176,10 @@ describe('common utils', () => {
alertId: ['a', 'b'],
index: '',
type: CommentType.alert,
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
},
],
},
@ -197,6 +213,10 @@ describe('common utils', () => {
alertId: ['a', 'b'],
index: '',
type: CommentType.alert,
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
},
],
},
@ -224,6 +244,10 @@ describe('common utils', () => {
alertId: ['a', 'b'],
index: '',
type: CommentType.alert,
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
},
],
},

View file

@ -717,6 +717,10 @@ describe('case connector', () => {
type: CommentType.alert,
alertId: 'test-id',
index: 'test-index',
rule: {
id: null,
name: null,
},
},
},
};

View file

@ -122,23 +122,48 @@ async function executor(
/**
* This converts a connector style generated alert ({_id: string} | {_id: string}[]) to the expected format of addComment.
*/
interface AttachmentAlerts {
ids: string[];
indices: string[];
rule: { id: string | null; name: string | null };
}
export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => {
if (isCommentGeneratedAlert(comment)) {
const alertId: string[] = [];
if (Array.isArray(comment.alerts)) {
alertId.push(
...comment.alerts.map((alert: { _id: string }) => {
return alert._id;
})
try {
const genAlerts: Array<{
_id: string;
_index: string;
ruleId: string | undefined;
ruleName: string | undefined;
}> = JSON.parse(
`${comment.alerts.substring(0, comment.alerts.lastIndexOf('__SEPARATOR__'))}]`.replace(
/__SEPARATOR__/gi,
','
)
);
} else {
alertId.push(comment.alerts._id);
const { ids, indices, rule } = genAlerts.reduce<AttachmentAlerts>(
(acc, { _id, _index, ruleId, ruleName }) => {
// Mutation is faster than destructing.
// Mutation usually leads to side effects but for this scenario it's ok to do it.
acc.ids.push(_id);
acc.indices.push(_index);
// We assume one rule per batch of alerts
acc.rule = { id: ruleId ?? null, name: ruleName ?? null };
return acc;
},
{ ids: [], indices: [], rule: { id: null, name: null } }
);
return {
type: CommentType.generatedAlert,
alertId: ids,
index: indices,
rule,
};
} catch (e) {
throw new Error(`Error parsing generated alert in case connector -> ${e.message}`);
}
return {
type: CommentType.generatedAlert,
alertId,
index: comment.index,
};
} else {
return comment;
}

View file

@ -17,17 +17,9 @@ const ContextTypeUserSchema = schema.object({
comment: schema.string(),
});
const AlertIDSchema = schema.object(
{
_id: schema.string(),
},
{ unknowns: 'ignore' }
);
const ContextTypeAlertGroupSchema = schema.object({
type: schema.literal(CommentType.generatedAlert),
alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]),
index: schema.string(),
alerts: schema.string(),
});
export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type;
@ -37,6 +29,10 @@ const ContextTypeAlertSchema = schema.object({
// allowing either an array or a single value to preserve the previous API of attaching a single alert ID
alertId: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]),
index: schema.string(),
rule: schema.object({
id: schema.nullable(schema.string()),
name: schema.nullable(schema.string()),
}),
});
export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type;

View file

@ -65,3 +65,27 @@ export const isCommentAlert = (
): comment is ContextTypeAlertSchemaType => {
return comment.type === CommentType.alert;
};
/**
* Separator field for the case connector alerts string parser.
*/
const separator = '__SEPARATOR__';
interface AlertIDIndex {
_id: string;
_index: string;
ruleId: string;
ruleName: string;
}
/**
* Creates the format that the connector's parser is expecting, it should result in something like this:
* [{"_id":"1","_index":"index1"}__SEPARATOR__{"_id":"id2","_index":"index2"}__SEPARATOR__]
*
* This should only be used for testing purposes.
*/
export function createAlertsString(alerts: AlertIDIndex[]) {
return `[${alerts.reduce((acc, alert) => {
return `${acc}${JSON.stringify(alert)}${separator}`;
}, '')}]`;
}

View file

@ -346,6 +346,10 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
},
pushed_at: null,
pushed_by: null,
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
updated_at: '2019-11-25T22:32:30.608Z',
updated_by: {
full_name: 'elastic',
@ -379,6 +383,10 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
},
pushed_at: null,
pushed_by: null,
rule: {
id: 'rule-id-2',
name: 'rule-name-2',
},
updated_at: '2019-11-25T22:32:30.608Z',
updated_by: {
full_name: 'elastic',

View file

@ -69,6 +69,10 @@ describe('PATCH comment', () => {
type: CommentType.alert,
alertId: 'new-id',
index: 'test-index',
rule: {
id: 'rule-id',
name: 'rule',
},
id: commentID,
version: 'WzYsMV0=',
},
@ -218,6 +222,10 @@ describe('PATCH comment', () => {
type: CommentType.alert,
alertId: 'test-id',
index: 'test-index',
rule: {
id: 'rule-id',
name: 'rule',
},
id: 'mock-comment-1',
version: 'WzEsMV0=',
},

View file

@ -68,6 +68,10 @@ describe('POST comment', () => {
type: CommentType.alert,
alertId: 'test-id',
index: 'test-index',
rule: {
id: 'rule-id',
name: 'rule-name',
},
},
});

View file

@ -81,6 +81,7 @@ describe('PATCH cases', () => {
"syncAlerts": true,
},
"status": "closed",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",
@ -154,6 +155,7 @@ describe('PATCH cases', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",
@ -222,6 +224,7 @@ describe('PATCH cases', () => {
"syncAlerts": true,
},
"status": "in-progress",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",

View file

@ -213,6 +213,7 @@ describe('POST cases', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",

View file

@ -9,9 +9,9 @@ import { schema } from '@kbn/config-schema';
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
import { CASE_USER_ACTIONS_URL } from '../../../../../common/constants';
import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common/constants';
export function initGetAllUserActionsApi({ router }: RouteDeps) {
export function initGetAllCaseUserActionsApi({ router }: RouteDeps) {
router.get(
{
path: CASE_USER_ACTIONS_URL,
@ -39,3 +39,34 @@ export function initGetAllUserActionsApi({ router }: RouteDeps) {
}
);
}
export function initGetAllSubCaseUserActionsApi({ router }: RouteDeps) {
router.get(
{
path: SUB_CASE_USER_ACTIONS_URL,
validate: {
params: schema.object({
case_id: schema.string(),
sub_case_id: schema.string(),
}),
},
},
async (context, request, response) => {
if (!context.case) {
return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' });
}
const caseClient = context.case.getCaseClient();
const caseId = request.params.case_id;
const subCaseId = request.params.sub_case_id;
try {
return response.ok({
body: await caseClient.getUserActions({ caseId, subCaseId }),
});
} catch (error) {
return response.customError(wrapError(error));
}
}
);
}

View file

@ -14,7 +14,10 @@ import { initPushCaseApi } from './cases/push_case';
import { initGetReportersApi } from './cases/reporters/get_reporters';
import { initGetCasesStatusApi } from './cases/status/get_status';
import { initGetTagsApi } from './cases/tags/get_tags';
import { initGetAllUserActionsApi } from './cases/user_actions/get_all_user_actions';
import {
initGetAllCaseUserActionsApi,
initGetAllSubCaseUserActionsApi,
} from './cases/user_actions/get_all_user_actions';
import { initDeleteCommentApi } from './cases/comments/delete_comment';
import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments';
@ -52,7 +55,8 @@ export function initCaseApi(deps: RouteDeps) {
initPatchCasesApi(deps);
initPostCaseApi(deps);
initPushCaseApi(deps);
initGetAllUserActionsApi(deps);
initGetAllCaseUserActionsApi(deps);
initGetAllSubCaseUserActionsApi(deps);
// Sub cases
initGetSubCaseApi(deps);
initPatchSubCasesApi(deps);

View file

@ -401,6 +401,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",
@ -440,6 +441,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"Data Destruction",
@ -483,6 +485,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",
@ -530,6 +533,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "closed",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",
@ -594,6 +598,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",
@ -649,6 +654,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",
@ -727,6 +733,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"LOLBins",
@ -781,6 +788,7 @@ describe('Utils', () => {
"syncAlerts": true,
},
"status": "open",
"subCaseIds": undefined,
"subCases": undefined,
"tags": Array [
"defacement",

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { isEmpty } from 'lodash';
import { badRequest, boomify, isBoom } from '@hapi/boom';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
@ -120,7 +121,8 @@ export interface AlertInfo {
const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => {
if (isCommentRequestTypeAlertOrGenAlert(comment)) {
acc.ids.push(...getAlertIds(comment));
acc.indices.add(comment.index);
const indices = Array.isArray(comment.index) ? comment.index : [comment.index];
indices.forEach((index) => acc.indices.add(index));
}
return acc;
};
@ -249,12 +251,14 @@ export const flattenCaseSavedObject = ({
totalComment = comments.length,
totalAlerts = 0,
subCases,
subCaseIds,
}: {
savedObject: SavedObject<ESCaseAttributes>;
comments?: Array<SavedObject<CommentAttributes>>;
totalComment?: number;
totalAlerts?: number;
subCases?: SubCaseResponse[];
subCaseIds?: string[];
}): CaseResponse => ({
id: savedObject.id,
version: savedObject.version ?? '0',
@ -264,6 +268,7 @@ export const flattenCaseSavedObject = ({
...savedObject.attributes,
connector: transformESConnectorToCaseConnector(savedObject.attributes.connector),
subCases,
subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined,
});
export const flattenSubCaseSavedObject = ({

View file

@ -63,6 +63,16 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
},
},
},
rule: {
properties: {
id: {
type: 'keyword',
},
name: {
type: 'keyword',
},
},
},
updated_at: {
type: 'date',
},

View file

@ -173,8 +173,9 @@ interface SanitizedComment {
type: CommentType;
}
interface SanitizedCommentAssociationType {
interface SanitizedCommentFoSubCases {
associationType: AssociationType;
rule: { id: string | null; name: string | null };
}
export const commentsMigrations = {
@ -192,11 +193,12 @@ export const commentsMigrations = {
},
'7.12.0': (
doc: SavedObjectUnsanitizedDoc<UnsanitizedComment>
): SavedObjectSanitizedDoc<SanitizedCommentAssociationType> => {
): SavedObjectSanitizedDoc<SanitizedCommentFoSubCases> => {
return {
...doc,
attributes: {
...doc.attributes,
rule: { id: null, name: null },
associationType: AssociationType.case,
},
references: doc.references || [],

View file

@ -16,6 +16,7 @@ import {
import { CommentType } from '../../../common/api/cases/comment';
import { CASES_URL } from '../../../common/constants';
import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common';
import { ContextTypeGeneratedAlertType, createAlertsString } from '../../connectors';
main();
@ -105,6 +106,18 @@ async function handleGenGroupAlerts(argv: any) {
}
console.log('Case id: ', caseID);
const comment: ContextTypeGeneratedAlertType = {
type: CommentType.generatedAlert,
alerts: createAlertsString(
argv.ids.map((id: string) => ({
_id: id,
_index: argv.signalsIndex,
ruleId: argv.ruleID,
ruleName: argv.ruleName,
}))
),
};
const executeResp = await client.request<
ActionTypeExecutorResult<CollectionWithSubCaseResponse>
>({
@ -115,11 +128,7 @@ async function handleGenGroupAlerts(argv: any) {
subAction: 'addComment',
subActionParams: {
caseId: caseID,
comment: {
type: CommentType.generatedAlert,
alerts: argv.ids.map((id: string) => ({ _id: id })),
index: argv.signalsIndex,
},
comment,
},
},
},
@ -175,6 +184,18 @@ async function main() {
type: 'string',
default: '.siem-signals-default',
},
ruleID: {
alias: 'ri',
describe: 'siem signals rule id',
type: 'string',
default: 'rule-id',
},
ruleName: {
alias: 'rn',
describe: 'siem signals rule name',
type: 'string',
default: 'rule-name',
},
})
.demandOption(['ids']);
},

View file

@ -11,6 +11,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
import { ElasticsearchClient } from 'kibana/server';
import { CaseStatuses } from '../../../common/api';
import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants';
export type AlertServiceContract = PublicMethodsOf<AlertService>;
@ -95,14 +96,14 @@ export class AlertService {
query: {
bool: {
filter: {
bool: {
should: ids.map((_id) => ({ match: { _id } })),
minimum_should_match: 1,
ids: {
values: ids,
},
},
},
},
},
size: MAX_ALERTS_PER_SUB_CASE,
ignore_unavailable: true,
});

View file

@ -13,11 +13,16 @@ import {
} from 'kibana/server';
import { CaseUserActionAttributes } from '../../../common/api';
import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../saved_object_types';
import {
CASE_USER_ACTION_SAVED_OBJECT,
CASE_SAVED_OBJECT,
SUB_CASE_SAVED_OBJECT,
} from '../../saved_object_types';
import { ClientArgs } from '..';
interface GetCaseUserActionArgs extends ClientArgs {
caseId: string;
subCaseId?: string;
}
export interface UserActionItem {
@ -41,18 +46,20 @@ export interface CaseUserActionServiceSetup {
export class CaseUserActionService {
constructor(private readonly log: Logger) {}
public setup = async (): Promise<CaseUserActionServiceSetup> => ({
getUserActions: async ({ client, caseId }: GetCaseUserActionArgs) => {
getUserActions: async ({ client, caseId, subCaseId }: GetCaseUserActionArgs) => {
try {
const id = subCaseId ?? caseId;
const type = subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT;
const caseUserActionInfo = await client.find({
type: CASE_USER_ACTION_SAVED_OBJECT,
fields: [],
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
hasReference: { type, id },
page: 1,
perPage: 1,
});
return await client.find({
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
hasReference: { type, id },
page: 1,
perPage: caseUserActionInfo.total,
sortField: 'action_at',

View file

@ -171,7 +171,7 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID];
/*
Rule notifications options
*/
export const ENABLE_CASE_CONNECTOR = false;
export const ENABLE_CASE_CONNECTOR = true;
export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
'.email',
'.slack',

View file

@ -79,7 +79,12 @@ describe('AddComment ', () => {
await waitFor(() => {
expect(onCommentSaving).toBeCalled();
expect(postComment).toBeCalledWith(addCommentProps.caseId, sampleData, onCommentPosted);
expect(postComment).toBeCalledWith({
caseId: addCommentProps.caseId,
data: sampleData,
subCaseId: undefined,
updateCase: onCommentPosted,
});
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('');
});
});

View file

@ -39,11 +39,15 @@ interface AddCommentProps {
onCommentSaving?: () => void;
onCommentPosted: (newCase: Case) => void;
showLoading?: boolean;
subCaseId?: string;
}
export const AddComment = React.memo(
forwardRef<AddCommentRefObject, AddCommentProps>(
({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => {
(
{ caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId },
ref
) => {
const { isLoading, postComment } = usePostComment();
const { form } = useForm<AddCommentFormSchema>({
@ -80,10 +84,15 @@ export const AddComment = React.memo(
if (onCommentSaving != null) {
onCommentSaving();
}
postComment(caseId, { ...data, type: CommentType.user }, onCommentPosted);
postComment({
caseId,
data: { ...data, type: CommentType.user },
updateCase: onCommentPosted,
subCaseId,
});
reset();
}
}, [onCommentPosted, onCommentSaving, postComment, reset, submit, caseId]);
}, [caseId, onCommentPosted, onCommentSaving, postComment, reset, submit, subCaseId]);
return (
<span id="add-comment-permLink">

View file

@ -9,7 +9,7 @@ import { Dispatch } from 'react';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { CaseStatuses } from '../../../../../case/common/api';
import { Case } from '../../containers/types';
import { Case, SubCase } from '../../containers/types';
import { UpdateCase } from '../../containers/use_get_cases';
import * as i18n from './translations';
@ -19,6 +19,9 @@ interface GetActions {
deleteCaseOnClick: (deleteCase: Case) => void;
}
const hasSubCases = (subCases: SubCase[] | null | undefined) =>
subCases != null && subCases?.length > 0;
export const getActions = ({
caseStatus,
dispatchUpdate,
@ -32,33 +35,34 @@ export const getActions = ({
type: 'icon',
'data-test-subj': 'action-delete',
},
caseStatus === CaseStatuses.open
? {
description: i18n.CLOSE_CASE,
icon: 'folderCheck',
name: i18n.CLOSE_CASE,
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: CaseStatuses.closed,
caseId: theCase.id,
version: theCase.version,
}),
type: 'icon',
'data-test-subj': 'action-close',
}
: {
description: i18n.REOPEN_CASE,
icon: 'folderExclamation',
name: i18n.REOPEN_CASE,
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: CaseStatuses.open,
caseId: theCase.id,
version: theCase.version,
}),
type: 'icon',
'data-test-subj': 'action-open',
},
{
available: (item) => caseStatus === CaseStatuses.open && !hasSubCases(item.subCases),
description: i18n.CLOSE_CASE,
icon: 'folderCheck',
name: i18n.CLOSE_CASE,
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: CaseStatuses.closed,
caseId: theCase.id,
version: theCase.version,
}),
type: 'icon',
'data-test-subj': 'action-close',
},
{
available: (item) => caseStatus !== CaseStatuses.open && !hasSubCases(item.subCases),
description: i18n.REOPEN_CASE,
icon: 'folderExclamation',
name: i18n.REOPEN_CASE,
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: CaseStatuses.open,
caseId: theCase.id,
version: theCase.version,
}),
type: 'icon',
'data-test-subj': 'action-open',
},
];

View file

@ -14,17 +14,20 @@ import {
EuiTableActionsColumnType,
EuiTableComputedColumnType,
EuiTableFieldDataColumnType,
HorizontalAlignment,
} from '@elastic/eui';
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
import styled from 'styled-components';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { CaseStatuses } from '../../../../../case/common/api';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { Case } from '../../containers/types';
import { Case, SubCase } from '../../containers/types';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import { CaseDetailsLink } from '../../../common/components/links';
import * as i18n from './translations';
import { Status } from '../status';
import { getSubCasesStatusCountsBadges, isSubCase } from './helpers';
import { ALERTS } from '../../../app/home/translations';
export type CasesColumns =
| EuiTableFieldDataColumnType<Case>
@ -54,10 +57,14 @@ export const getCasesColumns = (
const columns = [
{
name: i18n.NAME,
render: (theCase: Case) => {
render: (theCase: Case | SubCase) => {
if (theCase.id != null && theCase.title != null) {
const caseDetailsLinkComponent = !isModal ? (
<CaseDetailsLink detailName={theCase.id} title={theCase.title}>
<CaseDetailsLink
detailName={isSubCase(theCase) ? theCase.caseParentId : theCase.id}
title={theCase.title}
subCaseId={isSubCase(theCase) ? theCase.id : undefined}
>
{theCase.title}
</CaseDetailsLink>
) : (
@ -122,7 +129,17 @@ export const getCasesColumns = (
truncateText: true,
},
{
align: 'right' as HorizontalAlignment,
align: RIGHT_ALIGNMENT,
field: 'totalAlerts',
name: ALERTS,
sortable: true,
render: (totalAlerts: Case['totalAlerts']) =>
totalAlerts != null
? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`)
: getEmptyTagValue(),
},
{
align: RIGHT_ALIGNMENT,
field: 'totalComment',
name: i18n.COMMENTS,
sortable: true,
@ -183,6 +200,24 @@ export const getCasesColumns = (
return getEmptyTagValue();
},
},
{
name: i18n.STATUS,
render: (theCase: Case) => {
if (theCase?.subCases == null || theCase.subCases.length === 0) {
if (theCase.status == null) {
return getEmptyTagValue();
}
return <Status type={theCase.status} />;
}
const badges = getSubCasesStatusCountsBadges(theCase.subCases);
return badges.map(({ color, count }, index) => (
<EuiBadge key={index} color={color}>
{count}
</EuiBadge>
));
},
},
{
name: i18n.ACTIONS,
actions,

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui';
import styled from 'styled-components';
import { Case } from '../../containers/types';
import { CasesColumns } from './columns';
import { AssociationType } from '../../../../../case/common/api';
type ExpandedRowMap = Record<string, Element> | {};
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
const BasicTable = styled(EuiBasicTable)`
thead {
display: none;
}
tbody {
.euiTableCellContent {
padding: 8px !important;
}
.euiTableRowCell {
border: 0;
}
}
`;
BasicTable.displayName = 'BasicTable';
export const getExpandedRowMap = ({
data,
columns,
}: {
data: Case[] | null;
columns: CasesColumns[];
}): ExpandedRowMap => {
if (data == null) {
return {};
}
return data.reduce((acc, curr) => {
if (curr.subCases != null) {
const subCases = curr.subCases.map((subCase, index) => ({
...subCase,
caseParentId: curr.id,
title: `${curr.title} ${index + 1}`,
associationType: AssociationType.subCase,
}));
return {
...acc,
[curr.id]: (
<BasicTable
columns={columns}
data-test-subj={`sub-cases-table-${curr.id}`}
itemId="id"
items={subCases}
/>
),
};
} else {
return acc;
}
}, {});
};

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { filter } from 'lodash/fp';
import { AssociationType, CaseStatuses } from '../../../../../case/common/api';
import { Case, SubCase } from '../../containers/types';
import { statuses } from '../status';
export const isSubCase = (theCase: Case | SubCase): theCase is SubCase =>
(theCase as SubCase).caseParentId !== undefined &&
(theCase as SubCase).associationType === AssociationType.subCase;
export const getSubCasesStatusCountsBadges = (
subCases: SubCase[]
): Array<{ name: CaseStatuses; color: string; count: number }> => {
return [
{
name: CaseStatuses.open,
color: statuses[CaseStatuses.open].color,
count: filter({ status: CaseStatuses.open }, subCases).length,
},
{
name: CaseStatuses['in-progress'],
color: statuses[CaseStatuses['in-progress']].color,
count: filter({ status: CaseStatuses['in-progress'] }, subCases).length,
},
{
name: CaseStatuses.closed,
color: statuses[CaseStatuses.closed].color,
count: filter({ status: CaseStatuses.closed }, subCases).length,
},
];
};

View file

@ -210,9 +210,12 @@ describe('AllCases', () => {
id: null,
createdAt: null,
createdBy: null,
status: null,
subCases: null,
tags: null,
title: null,
totalComment: null,
totalAlerts: null,
},
],
},
@ -542,6 +545,7 @@ describe('AllCases', () => {
},
id: '1',
status: 'open',
subCaseIds: [],
tags: ['coke', 'pepsi'],
title: 'Another horrible breach!!',
totalAlerts: 0,

View file

@ -7,7 +7,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EuiBasicTable,
EuiBasicTable as _EuiBasicTable,
EuiContextMenuPanel,
EuiEmptyPrompt,
EuiFlexGroup,
@ -53,6 +53,7 @@ import { SecurityPageName } from '../../../app/types';
import { useKibana } from '../../../common/lib/kibana';
import { APP_ID } from '../../../../common/constants';
import { Stats } from '../status';
import { getExpandedRowMap } from './expanded_row';
const Div = styled.div`
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
@ -83,6 +84,14 @@ const getSortField = (field: string): SortFieldCase => {
return SortFieldCase.createdAt;
};
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
const BasicTable = styled(EuiBasicTable)`
.euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent {
padding: 8px 0 8px 32px;
}
`;
BasicTable.displayName = 'BasicTable';
interface AllCasesProps {
onRowClick?: (theCase?: Case) => void;
isModal?: boolean;
@ -130,7 +139,7 @@ export const AllCases = React.memo<AllCasesProps>(
isUpdated,
updateBulkStatus,
} = useUpdateCases();
const [deleteThisCase, setDeleteThisCase] = useState({
const [deleteThisCase, setDeleteThisCase] = useState<DeleteCase>({
title: '',
id: '',
});
@ -190,7 +199,7 @@ export const AllCases = React.memo<AllCasesProps>(
const toggleDeleteModal = useCallback(
(deleteCase: Case) => {
handleToggleModal();
setDeleteThisCase(deleteCase);
setDeleteThisCase({ id: deleteCase.id, title: deleteCase.title, type: deleteCase.type });
},
[handleToggleModal]
);
@ -201,7 +210,11 @@ export const AllCases = React.memo<AllCasesProps>(
if (caseIds.length === 1) {
const singleCase = selectedCases.find((theCase) => theCase.id === caseIds[0]);
if (singleCase) {
return setDeleteThisCase({ id: singleCase.id, title: singleCase.title });
return setDeleteThisCase({
id: singleCase.id,
title: singleCase.title,
type: singleCase.type,
});
}
}
const convertToDeleteCases: DeleteCase[] = caseIds.map((id) => ({ id }));
@ -315,6 +328,16 @@ export const AllCases = React.memo<AllCasesProps>(
() => getCasesColumns(userCanCrud ? actions : [], filterOptions.status, isModal),
[actions, filterOptions.status, userCanCrud, isModal]
);
const itemIdToExpandedRowMap = useMemo(
() =>
getExpandedRowMap({
columns: memoizedGetCasesColumns,
data: data.cases,
}),
[data.cases, memoizedGetCasesColumns]
);
const memoizedPagination = useMemo(
() => ({
pageIndex: queryParams.page - 1,
@ -330,7 +353,10 @@ export const AllCases = React.memo<AllCasesProps>(
};
const euiBasicTableSelectionProps = useMemo<EuiTableSelectionType<Case>>(
() => ({ onSelectionChange: setSelectedCases }),
() => ({
selectable: (theCase) => isEmpty(theCase.subCases),
onSelectionChange: setSelectedCases,
}),
[setSelectedCases]
);
const isCasesLoading = useMemo(
@ -472,12 +498,13 @@ export const AllCases = React.memo<AllCasesProps>(
)}
</UtilityBarSection>
</UtilityBar>
<EuiBasicTable
<BasicTable
columns={memoizedGetCasesColumns}
data-test-subj="cases-table"
isSelectable={userCanCrud && !isModal}
itemId="id"
items={data.cases}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
noItemsMessage={
<EuiEmptyPrompt
title={<h3>{i18n.NO_CASES}</h3>}

View file

@ -103,3 +103,7 @@ export const SERVICENOW_LINK_ARIA = i18n.translate(
defaultMessage: 'click to view the incident on servicenow',
}
);
export const STATUS = i18n.translate('xpack.securitySolution.case.caseTable.status', {
defaultMessage: 'Status',
});

View file

@ -8,7 +8,7 @@
import { AssociationType, CommentType } from '../../../../../case/common/api';
import { Comment } from '../../containers/types';
import { getRuleIdsFromComments, buildAlertsQuery } from './helpers';
import { getManualAlertIdsWithNoRuleId, buildAlertsQuery } from './helpers';
const comments: Comment[] = [
{
@ -19,6 +19,10 @@ const comments: Comment[] = [
id: 'comment-id',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: { username: 'elastic' },
rule: {
id: null,
name: null,
},
pushedAt: null,
pushedBy: null,
updatedAt: null,
@ -35,6 +39,10 @@ const comments: Comment[] = [
createdBy: { username: 'elastic' },
pushedAt: null,
pushedBy: null,
rule: {
id: 'rule-id-2',
name: 'rule-name-2',
},
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFc',
@ -42,9 +50,9 @@ const comments: Comment[] = [
];
describe('Case view helpers', () => {
describe('getRuleIdsFromComments', () => {
it('it returns the rules ids from the comments', () => {
expect(getRuleIdsFromComments(comments)).toEqual(['alert-id-1', 'alert-id-2']);
describe('getAlertIdsFromComments', () => {
it('it returns the alert id from the comments where rule is not defined', () => {
expect(getManualAlertIdsWithNoRuleId(comments)).toEqual(['alert-id-1']);
});
});
@ -54,13 +62,13 @@ describe('Case view helpers', () => {
query: {
bool: {
filter: {
bool: {
should: [{ match: { _id: 'alert-id-1' } }, { match: { _id: 'alert-id-2' } }],
minimum_should_match: 1,
ids: {
values: ['alert-id-1', 'alert-id-2'],
},
},
},
},
size: 10000,
});
});
});

View file

@ -5,28 +5,37 @@
* 2.0.
*/
import { isEmpty } from 'lodash';
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) {
export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => {
const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => {
if (comment.type === CommentType.alert && isEmpty(comment.rule.id)) {
const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId];
return [...ruleIds, ...ids];
ids.forEach((id) => alertIds.add(id));
return alertIds;
}
return alertIds;
}, new Set<string>());
return [...dedupeAlerts];
};
return ruleIds;
}, []);
export const buildAlertsQuery = (ruleIds: string[]) => ({
query: {
bool: {
filter: {
bool: {
should: ruleIds.map((_id) => ({ match: { _id } })),
minimum_should_match: 1,
// TODO we need to allow -> docValueFields: [{ field: "@timestamp" }],
export const buildAlertsQuery = (alertIds: string[]) => {
if (alertIds.length === 0) {
return {};
}
return {
query: {
bool: {
filter: {
ids: {
values: alertIds,
},
},
},
},
},
});
size: 10000,
};
};

View file

@ -629,6 +629,14 @@ describe('CaseView ', () => {
loading: true,
data: { hits: { hits: [] } },
}));
useGetCaseUserActionsMock.mockReturnValue({
caseServices: {},
caseUserActions: [],
hasDataToPush: false,
isError: false,
isLoading: true,
participants: [],
});
const wrapper = mount(
<TestProviders>

View file

@ -42,8 +42,6 @@ import {
normalizeActionConnector,
getNoneConnector,
} from '../configure_cases/utils';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { buildAlertsQuery, getRuleIdsFromComments } from './helpers';
import { DetailsPanel } from '../../../timelines/components/side_panel';
import { useSourcererScope } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
@ -55,6 +53,7 @@ import * as i18n from './translations';
interface Props {
caseId: string;
subCaseId?: string;
userCanCrud: boolean;
}
@ -87,32 +86,8 @@ export interface CaseProps extends Props {
updateCase: (newCase: Case) => void;
}
interface Signal {
rule: {
id: string;
name: string;
to: string;
from: string;
};
}
interface SignalHit {
_id: string;
_index: string;
_source: {
'@timestamp': string;
signal: Signal;
};
}
export type Alert = {
_id: string;
_index: string;
'@timestamp': string;
} & Signal;
export const CaseComponent = React.memo<CaseProps>(
({ caseId, caseData, fetchCase, updateCase, userCanCrud }) => {
({ caseId, caseData, fetchCase, subCaseId, updateCase, userCanCrud }) => {
const dispatch = useDispatch();
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
const allCasesLink = getCaseUrl(search);
@ -127,45 +102,18 @@ export const CaseComponent = React.memo<CaseProps>(
hasDataToPush,
isLoading: isLoadingUserActions,
participants,
} = useGetCaseUserActions(caseId, caseData.connector.id);
} = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId);
const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({
caseId,
subCaseId,
});
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,
'@timestamp': _source['@timestamp'],
..._source.signal,
},
}),
{}
) ?? {},
[alertsData?.hits.hits]
);
const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections);
// Update Fields
const onUpdateField = useCallback(
@ -350,10 +298,10 @@ export const CaseComponent = React.memo<CaseProps>(
);
useEffect(() => {
if (initLoadingData && !isLoadingUserActions && !isLoadingAlerts) {
if (initLoadingData && !isLoadingUserActions) {
setInitLoadingData(false);
}
}, [initLoadingData, isLoadingAlerts, isLoadingUserActions]);
}, [initLoadingData, isLoadingUserActions]);
const backOptions = useMemo(
() => ({
@ -435,18 +383,17 @@ export const CaseComponent = React.memo<CaseProps>(
{!initLoadingData && (
<>
<UserActionTree
caseServices={caseServices}
caseUserActions={caseUserActions}
connectors={connectors}
data={caseData}
fetchUserActions={fetchCaseUserActions.bind(null, caseData.id)}
caseServices={caseServices}
isLoadingDescription={isLoading && updateKey === 'description'}
isLoadingUserActions={isLoadingUserActions}
onShowAlertDetails={showAlert}
onUpdateField={onUpdateField}
updateCase={updateCase}
userCanCrud={userCanCrud}
alerts={alerts}
onShowAlertDetails={showAlert}
/>
<MyEuiHorizontalRule margin="s" />
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
@ -513,8 +460,8 @@ export const CaseComponent = React.memo<CaseProps>(
}
);
export const CaseView = React.memo(({ caseId, userCanCrud }: Props) => {
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId);
export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => {
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
if (isError) {
return null;
}
@ -531,6 +478,7 @@ export const CaseView = React.memo(({ caseId, userCanCrud }: Props) => {
return (
<CaseComponent
caseId={caseId}
subCaseId={subCaseId}
fetchCase={fetchCase}
caseData={data}
updateCase={updateCase}

View file

@ -10,7 +10,7 @@ import { EuiConfirmModal } from '@elastic/eui';
import * as i18n from './translations';
interface ConfirmDeleteCaseModalProps {
caseTitle: string;
caseTitle?: string;
isModalVisible: boolean;
isPlural: boolean;
onCancel: () => void;
@ -36,7 +36,13 @@ const ConfirmDeleteCaseModalComp: React.FC<ConfirmDeleteCaseModalProps> = ({
defaultFocusedButton="confirm"
onCancel={onCancel}
onConfirm={onConfirm}
title={isPlural ? i18n.DELETE_SELECTED_CASES : i18n.DELETE_TITLE(caseTitle)}
title={
isPlural
? i18n.DELETE_SELECTED_CASES
: caseTitle == null
? i18n.DELETE_THIS_CASE
: i18n.DELETE_TITLE(caseTitle)
}
>
{isPlural ? i18n.CONFIRM_QUESTION_PLURAL : i18n.CONFIRM_QUESTION}
</EuiConfirmModal>

View file

@ -14,6 +14,11 @@ export const DELETE_TITLE = (caseTitle: string) =>
defaultMessage: 'Delete "{caseTitle}"',
});
export const DELETE_THIS_CASE = (caseTitle: string) =>
i18n.translate('xpack.securitySolution.case.confirmDeleteCase.deleteThisCase', {
defaultMessage: 'Delete this case',
});
export const CONFIRM_QUESTION = i18n.translate(
'xpack.securitySolution.case.confirmDeleteCase.confirmQuestion',
{

View file

@ -27,9 +27,7 @@ const Container = styled.div`
const defaultAlertComment = {
type: CommentType.generatedAlert,
alerts: '{{context.alerts}}',
index: '{{context.rule.output_index}}',
ruleId: '{{context.rule.id}}',
alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{rule.id}}", "ruleName": "{{rule.name}}"}__SEPARATOR__{{/context.alerts}}]`,
};
const CaseParamsFields: React.FunctionComponent<ActionParamsProps<CaseActionParams>> = ({

View file

@ -5,10 +5,22 @@
* 2.0.
*/
import React, { memo, useMemo, useCallback } from 'react';
import { useGetCases } from '../../../containers/use_get_cases';
import {
EuiButton,
EuiButtonIcon,
EuiCallOut,
EuiTextColor,
EuiLoadingSpinner,
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import React, { memo, useEffect, useCallback, useState } from 'react';
import { CaseType } from '../../../../../../case/common/api';
import { Case } from '../../../containers/types';
import { useDeleteCases } from '../../../containers/use_delete_cases';
import { useGetCase } from '../../../containers/use_get_case';
import { ConfirmDeleteCaseModal } from '../../confirm_delete_case';
import { useCreateCaseModal } from '../../use_create_case_modal';
import { CasesDropdown, ADD_CASE_BUTTON_ID } from './cases_dropdown';
import * as i18n from './translations';
interface ExistingCaseProps {
selectedCase: string | null;
@ -16,37 +28,72 @@ interface ExistingCaseProps {
}
const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, selectedCase }) => {
const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases();
const { data, isLoading, isError } = useGetCase(selectedCase ?? '');
const [createdCase, setCreatedCase] = useState<Case | null>(null);
const onCaseCreated = useCallback(() => refetchCases(), [refetchCases]);
const { modal, openModal } = useCreateCaseModal({ onCaseCreated });
const onChange = useCallback(
(id: string) => {
if (id === ADD_CASE_BUTTON_ID) {
openModal();
return;
}
onCaseChanged(id);
const onCaseCreated = useCallback(
(newCase: Case) => {
onCaseChanged(newCase.id);
setCreatedCase(newCase);
},
[onCaseChanged, openModal]
[onCaseChanged]
);
const isCasesLoading = useMemo(
() => isLoadingCases.includes('cases') || isLoadingCases.includes('caseUpdate'),
[isLoadingCases]
);
const { modal, openModal } = useCreateCaseModal({ caseType: CaseType.collection, onCaseCreated });
// Delete case
const {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isLoading: isDeleting,
isDeleted,
isDisplayConfirmDeleteModal,
} = useDeleteCases();
useEffect(() => {
if (isDeleted) {
setCreatedCase(null);
onCaseChanged('');
dispatchResetIsDeleted();
}
}, [isDeleted, dispatchResetIsDeleted, onCaseChanged]);
useEffect(() => {
if (!isLoading && !isError && data != null) {
setCreatedCase(data);
onCaseChanged(data.id);
}
}, [data, isLoading, isError, onCaseChanged]);
return (
<>
<CasesDropdown
isLoading={isCasesLoading}
cases={cases.cases}
selectedCase={selectedCase ?? undefined}
onCaseChanged={onChange}
/>
{createdCase == null && isEmpty(selectedCase) && (
<EuiButton fill fullWidth onClick={openModal}>
{i18n.CREATE_CASE}
</EuiButton>
)}
{createdCase == null && isLoading && <EuiLoadingSpinner size="m" />}
{createdCase != null && !isLoading && (
<>
<EuiCallOut title={i18n.CONNECTED_CASE} color="success">
<EuiTextColor color="default">
{createdCase.title}{' '}
{!isDeleting && (
<EuiButtonIcon color="danger" onClick={handleToggleModal} iconType="trash" />
)}
{isDeleting && <EuiLoadingSpinner size="m" />}
</EuiTextColor>
</EuiCallOut>
<ConfirmDeleteCaseModal
caseTitle={createdCase.title}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={false}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [createdCase])}
/>
</>
)}
{modal}
</>
);

View file

@ -85,3 +85,17 @@ export const CASE_CONNECTOR_ADD_NEW_CASE = i18n.translate(
defaultMessage: 'Add new case',
}
);
export const CREATE_CASE = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.createCaseLabel',
{
defaultMessage: 'Create case',
}
);
export const CONNECTED_CASE = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.connectedCaseLabel',
{
defaultMessage: 'Connected case',
}
);

View file

@ -19,6 +19,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service'
import { useConnectors } from '../../containers/configure/use_connectors';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { Case } from '../../containers/types';
import { CaseType } from '../../../../../case/common/api';
const initialCaseValue: FormProps = {
description: '',
@ -30,10 +31,15 @@ const initialCaseValue: FormProps = {
};
interface Props {
caseType?: CaseType;
onSuccess?: (theCase: Case) => void;
}
export const FormContext: React.FC<Props> = ({ children, onSuccess }) => {
export const FormContext: React.FC<Props> = ({
caseType = CaseType.individual,
children,
onSuccess,
}) => {
const { connectors } = useConnectors();
const { connector: configurationConnector } = useCaseConfigure();
const { postCase } = usePostCase();
@ -61,6 +67,7 @@ export const FormContext: React.FC<Props> = ({ children, onSuccess }) => {
const updatedCase = await postCase({
...dataWithoutConnectorId,
type: caseType,
connector: connectorToUpdate,
settings: { syncAlerts },
});
@ -77,7 +84,7 @@ export const FormContext: React.FC<Props> = ({ children, onSuccess }) => {
}
}
},
[connectors, postCase, onSuccess, pushCaseToExternalService]
[caseType, connectors, postCase, onSuccess, pushCaseToExternalService]
);
const { form } = useForm<FormProps>({

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { CasePostRequest } from '../../../../../case/common/api';
import { CasePostRequest, CaseType } from '../../../../../case/common/api';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
import { choices } from '../connectors/mock';
@ -14,6 +14,7 @@ export const sampleData: CasePostRequest = {
description: 'what a great description',
tags: sampleTags,
title: 'what a cool title',
type: CaseType.individual,
connector: {
fields: null,
id: 'none',

View file

@ -163,10 +163,14 @@ describe('AddToCaseAction', () => {
wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click');
expect(postComment.mock.calls[0][0]).toBe('new-case');
expect(postComment.mock.calls[0][1]).toEqual({
expect(postComment.mock.calls[0][0].caseId).toBe('new-case');
expect(postComment.mock.calls[0][0].data).toEqual({
alertId: 'test-id',
index: 'test-index',
rule: {
id: null,
name: null,
},
type: 'alert',
});
});
@ -196,10 +200,14 @@ describe('AddToCaseAction', () => {
wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click');
expect(postComment.mock.calls[0][0]).toBe('selected-case');
expect(postComment.mock.calls[0][1]).toEqual({
expect(postComment.mock.calls[0][0].caseId).toBe('selected-case');
expect(postComment.mock.calls[0][0].data).toEqual({
alertId: 'test-id',
index: 'test-index',
rule: {
id: null,
name: null,
},
type: 'alert',
});
});
@ -208,7 +216,7 @@ describe('AddToCaseAction', () => {
usePostCommentMock.mockImplementation(() => {
return {
...defaultPostComment,
postComment: jest.fn().mockImplementation((caseId, data, updateCase) => updateCase()),
postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => updateCase()),
};
});

View file

@ -44,6 +44,7 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
}) => {
const eventId = ecsRowData._id;
const eventIndex = ecsRowData._index;
const rule = ecsRowData.signal?.rule;
const { navigateToApp } = useKibana().services.application;
const [, dispatchToaster] = useStateToaster();
@ -71,21 +72,25 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
const attachAlertToCase = useCallback(
(theCase: Case) => {
closeCaseFlyoutOpen();
postComment(
theCase.id,
{
postComment({
caseId: theCase.id,
data: {
type: CommentType.alert,
alertId: eventId,
index: eventIndex ?? '',
rule: {
id: rule?.id != null ? rule.id[0] : null,
name: rule?.name != null ? rule.name[0] : null,
},
},
() =>
updateCase: () =>
dispatchToaster({
type: 'addToaster',
toast: createUpdateSuccessToaster(theCase, onViewCaseClick),
})
);
}),
});
},
[closeCaseFlyoutOpen, postComment, eventId, eventIndex, dispatchToaster, onViewCaseClick]
[closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule, dispatchToaster, onViewCaseClick]
);
const onCaseClicked = useCallback(

View file

@ -14,11 +14,13 @@ import { CreateCaseForm } from '../create/form';
import { SubmitCaseButton } from '../create/submit_button';
import { Case } from '../../containers/types';
import * as i18n from '../../translations';
import { CaseType } from '../../../../../case/common/api';
export interface CreateCaseModalProps {
isModalOpen: boolean;
onCloseCaseModal: () => void;
onSuccess: (theCase: Case) => void;
caseType?: CaseType;
}
const Container = styled.div`
@ -32,6 +34,7 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
isModalOpen,
onCloseCaseModal,
onSuccess,
caseType = CaseType.individual,
}) => {
return isModalOpen ? (
<EuiModal onClose={onCloseCaseModal} data-test-subj="all-cases-modal">
@ -39,7 +42,7 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
<EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FormContext onSuccess={onSuccess}>
<FormContext caseType={caseType} onSuccess={onSuccess}>
<CreateCaseForm withSteps={false} />
<Container>
<SubmitCaseButton />

View file

@ -6,11 +6,13 @@
*/
import React, { useState, useCallback, useMemo } from 'react';
import { CaseType } from '../../../../../case/common/api';
import { Case } from '../../containers/types';
import { CreateCaseModal } from './create_case_modal';
export interface UseCreateCaseModalProps {
onCaseCreated: (theCase: Case) => void;
caseType?: CaseType;
}
export interface UseCreateCaseModalReturnedValues {
modal: JSX.Element;
@ -19,7 +21,10 @@ export interface UseCreateCaseModalReturnedValues {
openModal: () => void;
}
export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) => {
export const useCreateCaseModal = ({
caseType = CaseType.individual,
onCaseCreated,
}: UseCreateCaseModalProps) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const closeModal = useCallback(() => setIsModalOpen(false), []);
const openModal = useCallback(() => setIsModalOpen(true), []);
@ -35,6 +40,7 @@ export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) =
() => ({
modal: (
<CreateCaseModal
caseType={caseType}
isModalOpen={isModalOpen}
onCloseCaseModal={closeModal}
onSuccess={onSuccess}
@ -44,7 +50,7 @@ export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) =
closeModal,
openModal,
}),
[isModalOpen, closeModal, onSuccess, openModal]
[caseType, closeModal, isModalOpen, onSuccess, openModal]
);
return state;

View file

@ -5,13 +5,16 @@
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps, EuiIconTip } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiCommentProps } from '@elastic/eui';
import { isObject, get, isString, isNumber, isEmpty } from 'lodash';
import React, { useMemo } from 'react';
import { SearchResponse } from 'elasticsearch';
import {
CaseFullExternalService,
ActionConnector,
CaseStatuses,
CommentType,
} from '../../../../../case/common/api';
import { CaseUserActions } from '../../containers/types';
import { CaseServices } from '../../containers/use_get_case_user_actions';
@ -24,8 +27,16 @@ import { UserActionMoveToReference } from './user_action_move_to_reference';
import { Status, statuses } from '../status';
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';
import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action';
import { Ecs } from '../../../../common/ecs';
import { TimelineNonEcsData } from '../../../../common/search_strategy';
import { useSourcererScope } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { buildAlertsQuery } from '../case_view/helpers';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { KibanaServices } from '../../../common/lib/kibana';
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../common/constants';
interface LabelTitle {
action: CaseUserActions;
@ -194,14 +205,22 @@ export const getUpdateAction = ({
),
});
export const getAlertComment = ({
export const getAlertAttachment = ({
action,
alert,
alertId,
index,
loadingAlertData,
ruleId,
ruleName,
onShowAlertDetails,
}: {
action: CaseUserActions;
alert: Alert | undefined;
onShowAlertDetails: (alertId: string, index: string) => void;
alertId: string;
index: string;
loadingAlertData: boolean;
ruleId: string;
ruleName: string;
}): EuiCommentProps => {
return {
username: (
@ -212,7 +231,15 @@ export const getAlertComment = ({
),
className: 'comment-alert',
type: 'update',
event: <AlertCommentEvent alert={alert} />,
event: (
<AlertCommentEvent
alertId={alertId}
loadingAlertData={loadingAlertData}
ruleId={ruleId}
ruleName={ruleName}
commentType={CommentType.alert}
/>
),
'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`,
timestamp: <UserActionTimestamp createdAt={action.actionAt} />,
timelineIcon: 'bell',
@ -222,23 +249,188 @@ export const getAlertComment = ({
<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}
/>
)}
<UserActionShowAlert
id={action.actionId}
alertId={alertId}
index={index}
onShowAlertDetails={onShowAlertDetails}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
};
};
export const toStringArray = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.reduce<string[]>((acc, v) => {
if (v != null) {
switch (typeof v) {
case 'number':
case 'boolean':
return [...acc, v.toString()];
case 'object':
try {
return [...acc, JSON.stringify(v)];
} catch {
return [...acc, 'Invalid Object'];
}
case 'string':
return [...acc, v];
default:
return [...acc, `${v}`];
}
}
return acc;
}, []);
} else if (value == null) {
return [];
} else if (!Array.isArray(value) && typeof value === 'object') {
try {
return [JSON.stringify(value)];
} catch {
return ['Invalid Object'];
}
} else {
return [`${value}`];
}
};
export const formatAlertToEcsSignal = (alert: {}): Ecs =>
Object.keys(alert).reduce<Ecs>((accumulator, key) => {
const item = get(alert, key);
if (item != null && isObject(item)) {
return { ...accumulator, [key]: formatAlertToEcsSignal(item) };
} else if (Array.isArray(item) || isString(item) || isNumber(item)) {
return { ...accumulator, [key]: toStringArray(item) };
}
return accumulator;
}, {} as Ecs);
const EMPTY_ARRAY: TimelineNonEcsData[] = [];
export const getGeneratedAlertsAttachment = ({
action,
alertIds,
ruleId,
ruleName,
}: {
action: CaseUserActions;
alertIds: string[];
ruleId: string;
ruleName: string;
}): EuiCommentProps => {
const fetchEcsAlertsData = async (fetchAlertIds?: string[]): Promise<Ecs[]> => {
if (isEmpty(fetchAlertIds)) {
return [];
}
const alertResponse = await KibanaServices.get().http.fetch<
SearchResponse<{ '@timestamp': string; [key: string]: unknown }>
>(DETECTION_ENGINE_QUERY_SIGNALS_URL, {
method: 'POST',
body: JSON.stringify(buildAlertsQuery(fetchAlertIds ?? [])),
});
return (
alertResponse?.hits.hits.reduce<Ecs[]>(
(acc, { _id, _index, _source }) => [
...acc,
{
...formatAlertToEcsSignal(_source as {}),
_id,
_index,
timestamp: _source['@timestamp'],
},
],
[]
) ?? []
);
};
return {
username: <EuiIcon type="logoSecurity" size="m" />,
className: 'comment-alert',
type: 'update',
event: (
<AlertCommentEvent
alertId={alertIds[0]}
ruleId={ruleId}
ruleName={ruleName}
alertsCount={alertIds.length}
commentType={CommentType.generatedAlert}
/>
),
'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>
<InvestigateInTimelineAction
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE}
alertIds={alertIds}
key="investigate-in-timeline"
ecsRowData={null}
fetchEcsAlertsData={fetchEcsAlertsData}
nonEcsRowData={EMPTY_ARRAY}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
};
};
interface Signal {
rule: {
id: string;
name: string;
to: string;
from: string;
};
}
interface SignalHit {
_id: string;
_index: string;
_source: {
'@timestamp': string;
signal: Signal;
};
}
export interface Alert {
_id: string;
_index: string;
'@timestamp': string;
signal: Signal;
[key: string]: unknown;
}
export const useFetchAlertData = (alertIds: string[]): [boolean, Record<string, Ecs>] => {
const { selectedPatterns } = useSourcererScope(SourcererScopeName.detections);
const alertsQuery = useMemo(() => buildAlertsQuery(alertIds), [alertIds]);
const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts<SignalHit, unknown>(
alertsQuery,
selectedPatterns[0]
);
const alerts = useMemo(
() =>
alertsData?.hits.hits.reduce<Record<string, Ecs>>(
(acc, { _id, _index, _source }) => ({
...acc,
[_id]: {
...formatAlertToEcsSignal(_source),
_id,
_index,
timestamp: _source['@timestamp'],
},
}),
{}
) ?? {},
[alertsData?.hits.hits]
);
return [isLoadingAlerts, alerts];
};

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import classNames from 'classnames';
import {
EuiFlexGroup,
EuiFlexItem,
@ -14,9 +12,12 @@ import {
EuiCommentList,
EuiCommentProps,
} from '@elastic/eui';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import { isRight } from 'fp-ts/Either';
import * as i18n from './translations';
@ -24,23 +25,31 @@ 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, CommentType } from '../../../../../case/common/api';
import {
ActionConnector,
AlertCommentRequestRt,
CommentType,
ContextTypeUserRt,
} from '../../../../../case/common/api';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { parseString } from '../../containers/utils';
import { Alert, OnUpdateFields } from '../case_view';
import { OnUpdateFields } from '../case_view';
import {
getConnectorLabelTitle,
getLabelTitle,
getPushedServiceLabelTitle,
getPushInfo,
getUpdateAction,
getAlertComment,
getAlertAttachment,
getGeneratedAlertsAttachment,
useFetchAlertData,
} from './helpers';
import { UserActionAvatar } from './user_action_avatar';
import { UserActionMarkdown } from './user_action_markdown';
import { UserActionTimestamp } from './user_action_timestamp';
import { UserActionUsername } from './user_action_username';
import { UserActionContentToolbar } from './user_action_content_toolbar';
import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers';
export interface UserActionTreeProps {
caseServices: CaseServices;
@ -53,7 +62,6 @@ 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;
}
@ -112,10 +120,9 @@ export const UserActionTree = React.memo(
onUpdateField,
updateCase,
userCanCrud,
alerts,
onShowAlertDetails,
}: UserActionTreeProps) => {
const { commentId } = useParams<{ commentId?: string }>();
const { commentId, subCaseId } = useParams<{ commentId?: string; subCaseId?: string }>();
const handlerTimeoutId = useRef(0);
const addCommentRef = useRef<AddCommentRefObject>(null);
const [initLoading, setInitLoading] = useState(true);
@ -124,6 +131,10 @@ export const UserActionTree = React.memo(
const currentUser = useCurrentUser();
const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]);
const [loadingAlertData, manualAlertsData] = useFetchAlertData(
getManualAlertIdsWithNoRuleId(caseData.comments)
);
const handleManageMarkdownEditId = useCallback(
(id: string) => {
if (!manageMarkdownEditIds.includes(id)) {
@ -218,9 +229,10 @@ export const UserActionTree = React.memo(
onCommentPosted={handleUpdate}
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)}
showLoading={false}
subCaseId={subCaseId}
/>
),
[caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId]
[caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId, subCaseId]
);
useEffect(() => {
@ -279,11 +291,16 @@ export const UserActionTree = React.memo(
const userActions: EuiCommentProps[] = useMemo(
() =>
caseUserActions.reduce<EuiCommentProps[]>(
// eslint-disable-next-line complexity
(comments, action, index) => {
// Comment creation
if (action.commentId != null && action.action === 'create') {
const comment = caseData.comments.find((c) => c.id === action.commentId);
if (comment != null && comment.type === CommentType.user) {
if (
comment != null &&
isRight(ContextTypeUserRt.decode(comment)) &&
comment.type === CommentType.user
) {
return [
...comments,
{
@ -335,16 +352,65 @@ export const UserActionTree = React.memo(
),
},
];
// TODO: need to handle CommentType.generatedAlert here to
} else if (comment != null && comment.type === CommentType.alert) {
} else if (
comment != null &&
isRight(AlertCommentRequestRt.decode(comment)) &&
comment.type === CommentType.alert
) {
// TODO: clean this up
const alertId = Array.isArray(comment.alertId)
? comment.alertId.length > 0
? comment.alertId[0]
: ''
: comment.alertId;
const alert = alerts[alertId];
return [...comments, getAlertComment({ action, alert, onShowAlertDetails })];
const alertIndex = Array.isArray(comment.index)
? comment.index.length > 0
? comment.index[0]
: ''
: comment.index;
if (isEmpty(alertId)) {
return comments;
}
const ruleId = comment?.rule?.id ?? manualAlertsData[alertId]?.rule?.id?.[0] ?? '';
const ruleName =
comment?.rule?.name ??
manualAlertsData[alertId]?.rule?.name?.[0] ??
i18n.UNKNOWN_RULE;
return [
...comments,
getAlertAttachment({
action,
alertId,
index: alertIndex,
loadingAlertData,
ruleId,
ruleName,
onShowAlertDetails,
}),
];
} else if (comment != null && comment.type === CommentType.generatedAlert) {
// TODO: clean this up
const alertIds = Array.isArray(comment.alertId)
? comment.alertId
: [comment.alertId];
if (isEmpty(alertIds)) {
return comments;
}
return [
...comments,
getGeneratedAlertsAttachment({
action,
alertIds,
ruleId: comment.rule?.id ?? '',
ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE,
}),
];
}
}
@ -438,10 +504,11 @@ export const UserActionTree = React.memo(
handleManageQuote,
handleSaveComment,
isLoadingIds,
loadingAlertData,
manualAlertsData,
manageMarkdownEditIds,
selectedOutlineCommentId,
userCanCrud,
alerts,
onShowAlertDetails,
]
);

View file

@ -42,6 +42,19 @@ export const ALERT_COMMENT_LABEL_TITLE = i18n.translate(
}
);
export const GENERATED_ALERT_COMMENT_LABEL_TITLE = i18n.translate(
'xpack.securitySolution.case.caseView.generatedAlertCommentLabelTitle',
{
defaultMessage: 'were added from',
}
);
export const GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE = (totalCount: number) =>
i18n.translate('xpack.securitySolution.case.caseView.generatedAlertCountCommentLabelTitle', {
values: { totalCount },
defaultMessage: `{totalCount} {totalCount, plural, =1 {alert} other {alerts}}`,
});
export const ALERT_RULE_DELETED_COMMENT_LABEL = i18n.translate(
'xpack.securitySolution.case.caseView.alertRuleDeletedLabelTitle',
{
@ -56,9 +69,16 @@ export const SHOW_ALERT_TOOLTIP = i18n.translate(
}
);
export const ALERT_NOT_FOUND_TOOLTIP = i18n.translate(
'xpack.securitySolution.case.caseView.showAlertDeletedTooltip',
export const SEND_ALERT_TO_TIMELINE = i18n.translate(
'xpack.securitySolution.case.caseView.sendAlertToTimelineTooltip',
{
defaultMessage: 'Alert not found',
defaultMessage: 'Investigate in timeline',
}
);
export const UNKNOWN_RULE = i18n.translate(
'xpack.securitySolution.case.caseView.unknownRule.label',
{
defaultMessage: 'Unknown rule',
}
);

View file

@ -11,19 +11,14 @@ import { mount } from 'enzyme';
import { TestProviders } from '../../../common/mock';
import { useKibana } from '../../../common/lib/kibana';
import { AlertCommentEvent } from './user_action_alert_comment_event';
import { CommentType } from '../../../../../case/common/api';
const props = {
alert: {
_id: 'alert-id-1',
_index: 'alert-index-1',
'@timestamp': '2021-01-07T13:58:31.487Z',
rule: {
id: 'rule-id-1',
name: 'Awesome rule',
from: '2021-01-07T13:58:31.487Z',
to: '2021-01-07T14:58:31.487Z',
},
},
alertId: 'alert-id-1',
ruleId: 'rule-id-1',
ruleName: 'Awesome rule',
alertsCount: 1,
commentType: CommentType.alert,
};
jest.mock('../../../common/lib/kibana');
@ -54,7 +49,8 @@ describe('UserActionAvatar ', () => {
it('does NOT render the link when the alert is undefined', async () => {
const wrapper = mount(
<TestProviders>
<AlertCommentEvent alert={undefined} />
{/* @ts-expect-error */}
<AlertCommentEvent alert={undefined} commentType={CommentType.alert} />
</TestProviders>
);
@ -62,13 +58,13 @@ describe('UserActionAvatar ', () => {
wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists()
).toBeFalsy();
expect(wrapper.text()).toBe('added an alert');
expect(wrapper.text()).toBe('added an alert from ');
});
it('does NOT render the link when the rule is undefined', async () => {
const alert = {
_id: 'alert-id-1',
_index: 'alert-index-1',
alertId: 'alert-id-1',
commentType: CommentType.alert,
};
const wrapper = mount(
@ -82,7 +78,7 @@ describe('UserActionAvatar ', () => {
wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists()
).toBeFalsy();
expect(wrapper.text()).toBe('added an alert');
expect(wrapper.text()).toBe('added an alert from ');
});
it('navigate to app on link click', async () => {

View file

@ -6,24 +6,36 @@
*/
import React, { memo, useCallback } from 'react';
import { EuiLink } from '@elastic/eui';
import { EuiText, EuiLoadingSpinner } from '@elastic/eui';
import { APP_ID } from '../../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { getRuleDetailsUrl } from '../../../common/components/link_to';
import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to';
import { SecurityPageName } from '../../../app/types';
import { Alert } from '../case_view';
import * as i18n from './translations';
import { CommentType } from '../../../../../case/common/api';
import { LinkAnchor } from '../../../common/components/links';
interface Props {
alert: Alert | undefined;
alertId: string;
commentType: CommentType;
ruleId: string;
ruleName: string;
alertsCount?: number;
loadingAlertData?: boolean;
}
const AlertCommentEventComponent: React.FC<Props> = ({ alert }) => {
const ruleName = alert?.rule?.name ?? null;
const ruleId = alert?.rule?.id ?? null;
const AlertCommentEventComponent: React.FC<Props> = ({
alertId,
loadingAlertData = false,
ruleId,
ruleName,
alertsCount,
commentType,
}) => {
const { navigateToApp } = useKibana().services.application;
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.detections);
const onLinkClick = useCallback(
(ev: { preventDefault: () => void }) => {
@ -35,15 +47,37 @@ const AlertCommentEventComponent: React.FC<Props> = ({ alert }) => {
[ruleId, navigateToApp]
);
return ruleId != null && ruleName != null ? (
return commentType !== CommentType.generatedAlert ? (
<>
{`${i18n.ALERT_COMMENT_LABEL_TITLE} `}
<EuiLink onClick={onLinkClick} data-test-subj={`alert-rule-link-${alert?._id ?? 'deleted'}`}>
{ruleName}
</EuiLink>
{loadingAlertData && <EuiLoadingSpinner size="m" />}
{!loadingAlertData && ruleId !== '' && (
<LinkAnchor
onClick={onLinkClick}
href={formatUrl(getRuleDetailsUrl(ruleId ?? '', urlSearch))}
data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`}
>
{ruleName}
</LinkAnchor>
)}
{!loadingAlertData && ruleId === '' && <EuiText>{ruleName}</EuiText>}
</>
) : (
<>{i18n.ALERT_RULE_DELETED_COMMENT_LABEL}</>
<>
<b>{i18n.GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE(alertsCount ?? 0)}</b>{' '}
{i18n.GENERATED_ALERT_COMMENT_LABEL_TITLE}{' '}
{loadingAlertData && <EuiLoadingSpinner size="m" />}
{!loadingAlertData && ruleId !== '' && (
<LinkAnchor
onClick={onLinkClick}
href={formatUrl(getRuleDetailsUrl(ruleId ?? '', urlSearch))}
data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`}
>
{ruleName}
</LinkAnchor>
)}
{!loadingAlertData && ruleId === '' && <EuiText>{ruleName}</EuiText>}
</>
);
};

View file

@ -18,24 +18,26 @@ interface UserActionCopyLinkProps {
id: string;
}
const UserActionCopyLinkComponent = ({ id }: UserActionCopyLinkProps) => {
const { detailName: caseId } = useParams<{ detailName: string }>();
const UserActionCopyLinkComponent = ({ id: commentId }: UserActionCopyLinkProps) => {
const { detailName: caseId, subCaseId } = useParams<{ detailName: string; subCaseId?: string }>();
const { formatUrl } = useFormatUrl(SecurityPageName.case);
const handleAnchorLink = useCallback(() => {
copy(
formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId: id }), { absolute: true })
formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId, subCaseId }), {
absolute: true,
})
);
}, [caseId, formatUrl, id]);
}, [caseId, commentId, formatUrl, subCaseId]);
return (
<EuiToolTip position="top" content={<p>{i18n.COPY_REFERENCE_LINK}</p>}>
<EuiButtonIcon
aria-label={i18n.COPY_REFERENCE_LINK}
data-test-subj={`copy-link-${id}`}
data-test-subj={`copy-link-${commentId}`}
onClick={handleAnchorLink}
iconType="link"
id={`${id}-permLink`}
id={`${commentId}-permLink`}
/>
</EuiToolTip>
);

View file

@ -8,20 +8,24 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionShowAlert } from './user_action_show_alert';
import { RuleEcs } from '../../../../common/ecs/rule';
const props = {
id: 'action-id',
alertId: 'alert-id',
index: 'alert-index',
alert: {
_id: 'alert-id',
_index: 'alert-index',
'@timestamp': '2021-01-07T13:58:31.487Z',
timestamp: '2021-01-07T13:58:31.487Z',
rule: {
id: 'rule-id',
name: 'Awesome Rule',
from: '2021-01-07T13:58:31.487Z',
to: '2021-01-07T14:58:31.487Z',
},
id: ['rule-id'],
name: ['Awesome Rule'],
from: ['2021-01-07T13:58:31.487Z'],
to: ['2021-01-07T14:58:31.487Z'],
} as RuleEcs,
},
onShowAlertDetails: jest.fn(),
};
describe('UserActionShowAlert ', () => {

View file

@ -7,25 +7,24 @@
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;
alertId: string;
index: string;
onShowAlertDetails: (alertId: string, index: string) => void;
}
const UserActionShowAlertComponent = ({
id,
alert,
alertId,
index,
onShowAlertDetails,
}: UserActionShowAlertProps) => {
const onClick = useCallback(() => onShowAlertDetails(alert._id, alert._index), [
alert._id,
alert._index,
const onClick = useCallback(() => onShowAlertDetails(alertId, index), [
alertId,
index,
onShowAlertDetails,
]);
return (
@ -41,10 +40,4 @@ const UserActionShowAlertComponent = ({
);
};
export const UserActionShowAlert = memo(
UserActionShowAlertComponent,
(prevProps, nextProps) =>
prevProps.id === nextProps.id &&
deepEqual(prevProps.alert, nextProps.alert) &&
prevProps.onShowAlertDetails === nextProps.onShowAlertDetails
);
export const UserActionShowAlert = memo(UserActionShowAlertComponent);

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { assign } from 'lodash';
import {
CasePatchRequest,
CasePostRequest,
@ -16,6 +18,9 @@ import {
CaseUserActionsResponse,
CommentRequest,
CommentType,
SubCasePatchRequest,
SubCaseResponse,
SubCasesResponse,
User,
} from '../../../../case/common/api';
@ -25,6 +30,8 @@ import {
CASE_STATUS_URL,
CASE_TAGS_URL,
CASES_URL,
SUB_CASE_DETAILS_URL,
SUB_CASES_PATCH_DEL_URL,
} from '../../../../case/common/constants';
import {
@ -32,6 +39,8 @@ import {
getCasePushUrl,
getCaseDetailsUrl,
getCaseUserActionUrl,
getSubCaseDetailsUrl,
getSubCaseUserActionUrl,
} from '../../../../case/common/api/helpers';
import { KibanaServices } from '../../common/lib/kibana';
@ -73,6 +82,34 @@ export const getCase = async (
return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response));
};
export const getSubCase = async (
caseId: string,
subCaseId: string,
includeComments: boolean = true,
signal: AbortSignal
): Promise<Case> => {
const [caseResponse, subCaseResponse] = await Promise.all([
KibanaServices.get().http.fetch<CaseResponse>(getCaseDetailsUrl(caseId), {
method: 'GET',
query: {
includeComments: false,
},
signal,
}),
KibanaServices.get().http.fetch<SubCaseResponse>(getSubCaseDetailsUrl(caseId, subCaseId), {
method: 'GET',
query: {
includeComments,
},
signal,
}),
]);
const response = assign<CaseResponse, SubCaseResponse>(caseResponse, subCaseResponse);
const subCaseIndex = response.subCaseIds?.findIndex((scId) => scId === response.id) ?? -1;
response.title = `${response.title}${subCaseIndex >= 0 ? ` ${subCaseIndex + 1}` : ''}`;
return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response));
};
export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> => {
const response = await KibanaServices.get().http.fetch<CasesStatusResponse>(CASE_STATUS_URL, {
method: 'GET',
@ -111,6 +148,21 @@ export const getCaseUserActions = async (
return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[];
};
export const getSubCaseUserActions = async (
caseId: string,
subCaseId: string,
signal: AbortSignal
): Promise<CaseUserActions[]> => {
const response = await KibanaServices.get().http.fetch<CaseUserActionsResponse>(
getSubCaseUserActionUrl(caseId, subCaseId),
{
method: 'GET',
signal,
}
);
return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[];
};
export const getCases = async ({
filterOptions = {
search: '',
@ -167,6 +219,35 @@ export const patchCase = async (
return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response));
};
export const patchSubCase = async (
caseId: string,
subCaseId: string,
updatedSubCase: Pick<SubCasePatchRequest, 'status'>,
version: string,
signal: AbortSignal
): Promise<Case[]> => {
const subCaseResponse = await KibanaServices.get().http.fetch<SubCasesResponse>(
SUB_CASE_DETAILS_URL,
{
method: 'PATCH',
body: JSON.stringify({ cases: [{ ...updatedSubCase, id: caseId, version }] }),
signal,
}
);
const caseResponse = await KibanaServices.get().http.fetch<CaseResponse>(
getCaseDetailsUrl(caseId),
{
method: 'GET',
query: {
includeComments: false,
},
signal,
}
);
const response = subCaseResponse.map((subCaseResp) => assign(caseResponse, subCaseResp));
return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response));
};
export const patchCasesStatus = async (
cases: BulkUpdateStatus[],
signal: AbortSignal
@ -182,13 +263,15 @@ export const patchCasesStatus = async (
export const postComment = async (
newComment: CommentRequest,
caseId: string,
signal: AbortSignal
signal: AbortSignal,
subCaseId?: string
): Promise<Case> => {
const response = await KibanaServices.get().http.fetch<CaseResponse>(
`${CASES_URL}/${caseId}/comments`,
{
method: 'POST',
body: JSON.stringify(newComment),
...(subCaseId ? { query: { subCaseId } } : {}),
signal,
}
);
@ -200,7 +283,8 @@ export const patchComment = async (
commentId: string,
commentUpdate: string,
version: string,
signal: AbortSignal
signal: AbortSignal,
subCaseId?: string
): Promise<Case> => {
const response = await KibanaServices.get().http.fetch<CaseResponse>(getCaseCommentsUrl(caseId), {
method: 'PATCH',
@ -210,6 +294,7 @@ export const patchComment = async (
id: commentId,
version,
}),
...(subCaseId ? { query: { subCaseId } } : {}),
signal,
});
return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response));
@ -224,6 +309,15 @@ export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promi
return response;
};
export const deleteSubCases = async (caseIds: string[], signal: AbortSignal): Promise<string> => {
const response = await KibanaServices.get().http.fetch<string>(SUB_CASES_PATCH_DEL_URL, {
method: 'DELETE',
query: { ids: JSON.stringify(caseIds) },
signal,
});
return response;
};
export const pushCase = async (
caseId: string,
connectorId: string,

View file

@ -26,6 +26,7 @@ import { ConnectorTypes } from '../../../../case/common/api/connectors';
export { connectorsMock } from './configure/mock';
export const basicCaseId = 'basic-case-id';
export const basicSubCaseId = 'basic-sub-case-id';
const basicCommentId = 'basic-comment-id';
const basicCreatedAt = '2020-02-19T23:06:33.798Z';
const basicUpdatedAt = '2020-02-20T15:02:57.995Z';
@ -63,6 +64,10 @@ export const alertComment: Comment = {
createdBy: elasticUser,
pushedAt: null,
pushedBy: null,
rule: {
id: 'rule-id-1',
name: 'Awesome rule',
},
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFc',
@ -95,6 +100,7 @@ export const basicCase: Case = {
settings: {
syncAlerts: true,
},
subCaseIds: [],
};
export const basicCasePost: Case = {
@ -217,7 +223,7 @@ export const basicCaseSnake: CaseResponse = {
external_service: null,
updated_at: basicUpdatedAt,
updated_by: elasticUserSnake,
};
} as CaseResponse;
export const casesStatusSnake: CasesStatusResponse = {
count_closed_cases: 130,

View file

@ -18,7 +18,9 @@ import {
AssociationType,
} from '../../../../case/common/api';
export { CaseConnector, ActionConnector } from '../../../../case/common/api';
export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../case/common/api';
export type AllCaseType = AssociationType & CaseType;
export type Comment = CommentRequest & {
associationType: AssociationType;
@ -52,26 +54,37 @@ export interface CaseExternalService {
externalTitle: string;
externalUrl: string;
}
export interface Case {
interface BasicCase {
id: string;
closedAt: string | null;
closedBy: ElasticUser | null;
comments: Comment[];
connector: CaseConnector;
createdAt: string;
createdBy: ElasticUser;
description: string;
externalService: CaseExternalService | null;
status: CaseStatuses;
tags: string[];
title: string;
totalAlerts: number;
totalComment: number;
type: CaseType;
updatedAt: string | null;
updatedBy: ElasticUser | null;
version: string;
}
export interface SubCase extends BasicCase {
associationType: AssociationType;
caseParentId: string;
}
export interface Case extends BasicCase {
connector: CaseConnector;
description: string;
externalService: CaseExternalService | null;
subCases?: SubCase[] | null;
subCaseIds: string[];
settings: CaseAttributes['settings'];
tags: string[];
type: CaseType;
}
export interface QueryParams {
@ -138,6 +151,7 @@ export interface ActionLicense {
export interface DeleteCase {
id: string;
title?: string;
type?: CaseType;
}
export interface FieldMappings {
@ -153,7 +167,7 @@ export type UpdateKey = keyof Pick<
export interface UpdateByKey {
updateKey: UpdateKey;
updateValue: CasePatchRequest[UpdateKey];
fetchCaseUserActions?: (caseId: string) => void;
fetchCaseUserActions?: (caseId: string, subCaseId?: string) => void;
updateCase?: (newCase: Case) => void;
caseData: Case;
onSuccess?: () => void;

View file

@ -12,7 +12,7 @@ import {
useStateToaster,
} from '../../common/components/toasters';
import * as i18n from './translations';
import { deleteCases } from './api';
import { deleteCases, deleteSubCases } from './api';
import { DeleteCase } from './types';
interface DeleteState {
@ -87,7 +87,13 @@ export const useDeleteCases = (): UseDeleteCase => {
try {
dispatch({ type: 'FETCH_INIT' });
const caseIds = cases.map((theCase) => theCase.id);
await deleteCases(caseIds, abortCtrl.signal);
// We don't allow user batch delete sub cases on UI at the moment.
if (cases[0].type != null || cases.length > 1) {
await deleteCases(caseIds, abortCtrl.signal);
} else {
await deleteSubCases(caseIds, abortCtrl.signal);
}
if (!cancel) {
dispatch({ type: 'FETCH_SUCCESS', payload: true });
displaySuccessToast(

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import { useEffect, useReducer, useCallback } from 'react';
import { isEmpty } from 'lodash';
import { useEffect, useReducer, useCallback, useRef } from 'react';
import { CaseStatuses, CaseType } from '../../../../case/common/api';
import { Case } from './types';
import * as i18n from './translations';
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
import { getCase } from './api';
import { getCase, getSubCase } from './api';
import { getNoneConnector } from '../components/configure_cases/utils';
interface CaseState {
@ -77,6 +78,7 @@ export const initialData: Case = {
updatedAt: null,
updatedBy: null,
version: '',
subCaseIds: [],
settings: {
syncAlerts: true,
},
@ -87,31 +89,32 @@ export interface UseGetCase extends CaseState {
updateCase: (newCase: Case) => void;
}
export const useGetCase = (caseId: string): UseGetCase => {
export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: true,
isError: false,
data: initialData,
});
const [, dispatchToaster] = useStateToaster();
const abortCtrl = useRef(new AbortController());
const didCancel = useRef(false);
const updateCase = useCallback((newCase: Case) => {
dispatch({ type: 'UPDATE_CASE', payload: newCase });
}, []);
const callFetch = useCallback(async () => {
let didCancel = false;
const abortCtrl = new AbortController();
const fetchData = async () => {
dispatch({ type: 'FETCH_INIT' });
try {
const response = await getCase(caseId, true, abortCtrl.signal);
if (!didCancel) {
const response = await (subCaseId
? getSubCase(caseId, subCaseId, true, abortCtrl.current.signal)
: getCase(caseId, true, abortCtrl.current.signal));
if (!didCancel.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });
}
} catch (error) {
if (!didCancel) {
if (!didCancel.current) {
errorToToaster({
title: i18n.ERROR_TITLE,
error: error.body && error.body.message ? new Error(error.body.message) : error,
@ -121,17 +124,22 @@ export const useGetCase = (caseId: string): UseGetCase => {
}
}
};
didCancel.current = false;
abortCtrl.current.abort();
abortCtrl.current = new AbortController();
fetchData();
return () => {
didCancel = true;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseId]);
}, [caseId, subCaseId]);
useEffect(() => {
callFetch();
if (!isEmpty(caseId)) {
callFetch();
}
return () => {
didCancel.current = true;
abortCtrl.current.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseId]);
}, [caseId, subCaseId]);
return { ...state, fetchCase: callFetch, updateCase };
};

View file

@ -6,12 +6,12 @@
*/
import { isEmpty, uniqBy } from 'lodash/fp';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
import { CaseFullExternalService } from '../../../../case/common/api/cases';
import { getCaseUserActions } from './api';
import { getCaseUserActions, getSubCaseUserActions } from './api';
import * as i18n from './translations';
import { CaseConnector, CaseExternalService, CaseUserActions, ElasticUser } from './types';
import { convertToCamelCase, parseString } from './utils';
@ -46,7 +46,7 @@ export const initialData: CaseUserActionsState = {
};
export interface UseGetCaseUserActions extends CaseUserActionsState {
fetchCaseUserActions: (caseId: string) => void;
fetchCaseUserActions: (caseId: string, subCaseId?: string) => void;
}
const getExternalService = (value: string): CaseExternalService | null =>
@ -238,26 +238,29 @@ export const getPushedInfo = (
export const useGetCaseUserActions = (
caseId: string,
caseConnectorId: string
caseConnectorId: string,
subCaseId?: string
): UseGetCaseUserActions => {
const [caseUserActionsState, setCaseUserActionsState] = useState<CaseUserActionsState>(
initialData
);
const abortCtrl = useRef(new AbortController());
const didCancel = useRef(false);
const [, dispatchToaster] = useStateToaster();
const fetchCaseUserActions = useCallback(
(thisCaseId: string) => {
let didCancel = false;
const abortCtrl = new AbortController();
(thisCaseId: string, thisSubCaseId?: string) => {
const fetchData = async () => {
setCaseUserActionsState({
...caseUserActionsState,
isLoading: true,
});
try {
const response = await getCaseUserActions(thisCaseId, abortCtrl.signal);
if (!didCancel) {
setCaseUserActionsState({
...caseUserActionsState,
isLoading: true,
});
const response = await (thisSubCaseId
? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrl.current.signal)
: getCaseUserActions(thisCaseId, abortCtrl.current.signal));
if (!didCancel.current) {
// Attention Future developer
// We are removing the first item because it will always be the creation of the case
// and we do not want it to simplify our life
@ -265,7 +268,11 @@ export const useGetCaseUserActions = (
? uniqBy('actionBy.username', response).map((cau) => cau.actionBy)
: [];
const caseUserActions = !isEmpty(response) ? response.slice(1) : [];
const caseUserActions = !isEmpty(response)
? thisSubCaseId
? response
: response.slice(1)
: [];
setCaseUserActionsState({
caseUserActions,
...getPushedInfo(caseUserActions, caseConnectorId),
@ -275,7 +282,7 @@ export const useGetCaseUserActions = (
});
}
} catch (error) {
if (!didCancel) {
if (!didCancel.current) {
errorToToaster({
title: i18n.ERROR_TITLE,
error: error.body && error.body.message ? new Error(error.body.message) : error,
@ -292,21 +299,24 @@ export const useGetCaseUserActions = (
}
}
};
abortCtrl.current.abort();
abortCtrl.current = new AbortController();
fetchData();
return () => {
didCancel = true;
abortCtrl.abort();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[caseUserActionsState, caseConnectorId]
[caseConnectorId]
);
useEffect(() => {
if (!isEmpty(caseId)) {
fetchCaseUserActions(caseId);
fetchCaseUserActions(caseId, subCaseId);
}
return () => {
didCancel.current = true;
abortCtrl.current.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseId, caseConnectorId]);
}, [caseId, subCaseId]);
return { ...caseUserActionsState, fetchCaseUserActions };
};

View file

@ -9,7 +9,7 @@ import { renderHook, act } from '@testing-library/react-hooks';
import { CommentType } from '../../../../case/common/api';
import { usePostComment, UsePostComment } from './use_post_comment';
import { basicCaseId } from './mock';
import { basicCaseId, basicSubCaseId } from './mock';
import * as api from './api';
jest.mock('./api');
@ -40,7 +40,7 @@ describe('usePostComment', () => {
});
});
it('calls postComment with correct arguments', async () => {
it('calls postComment with correct arguments - case', async () => {
const spyOnPostCase = jest.spyOn(api, 'postComment');
await act(async () => {
@ -49,9 +49,38 @@ describe('usePostComment', () => {
);
await waitForNextUpdate();
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
result.current.postComment({
caseId: basicCaseId,
data: samplePost,
updateCase: updateCaseCallback,
});
await waitForNextUpdate();
expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal);
expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal, undefined);
});
});
it('calls postComment with correct arguments - sub case', async () => {
const spyOnPostCase = jest.spyOn(api, 'postComment');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() =>
usePostComment()
);
await waitForNextUpdate();
result.current.postComment({
caseId: basicCaseId,
data: samplePost,
updateCase: updateCaseCallback,
subCaseId: basicSubCaseId,
});
await waitForNextUpdate();
expect(spyOnPostCase).toBeCalledWith(
samplePost,
basicCaseId,
abortCtrl.signal,
basicSubCaseId
);
});
});
@ -61,7 +90,11 @@ describe('usePostComment', () => {
usePostComment()
);
await waitForNextUpdate();
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
result.current.postComment({
caseId: basicCaseId,
data: samplePost,
updateCase: updateCaseCallback,
});
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
@ -77,7 +110,11 @@ describe('usePostComment', () => {
usePostComment()
);
await waitForNextUpdate();
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
result.current.postComment({
caseId: basicCaseId,
data: samplePost,
updateCase: updateCaseCallback,
});
expect(result.current.isLoading).toBe(true);
});
@ -94,7 +131,11 @@ describe('usePostComment', () => {
usePostComment()
);
await waitForNextUpdate();
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
result.current.postComment({
caseId: basicCaseId,
data: samplePost,
updateCase: updateCaseCallback,
});
expect(result.current).toEqual({
isLoading: false,

View file

@ -42,8 +42,14 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta
}
};
interface PostComment {
caseId: string;
data: CommentRequest;
updateCase?: (newCase: Case) => void;
subCaseId?: string;
}
export interface UsePostComment extends NewCommentState {
postComment: (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => void;
postComment: (args: PostComment) => void;
}
export const usePostComment = (): UsePostComment => {
@ -54,13 +60,13 @@ export const usePostComment = (): UsePostComment => {
const [, dispatchToaster] = useStateToaster();
const postMyComment = useCallback(
async (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => {
async ({ caseId, data, updateCase, subCaseId }: PostComment) => {
let cancel = false;
const abortCtrl = new AbortController();
try {
dispatch({ type: 'FETCH_INIT' });
const response = await postComment(data, caseId, abortCtrl.signal);
const response = await postComment(data, caseId, abortCtrl.signal, subCaseId);
if (!cancel) {
dispatch({ type: 'FETCH_SUCCESS' });
if (updateCase) {

View file

@ -7,7 +7,7 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useUpdateCase, UseUpdateCase } from './use_update_case';
import { basicCase } from './mock';
import { basicCase, basicSubCaseId } from './mock';
import * as api from './api';
import { UpdateKey } from './types';
@ -84,7 +84,27 @@ describe('useUpdateCase', () => {
isError: false,
updateCaseProperty: result.current.updateCaseProperty,
});
expect(fetchCaseUserActions).toBeCalledWith(basicCase.id);
expect(fetchCaseUserActions).toBeCalledWith(basicCase.id, undefined);
expect(updateCase).toBeCalledWith(basicCase);
expect(onSuccess).toHaveBeenCalled();
});
});
it('patch sub case', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseUpdateCase>(() =>
useUpdateCase({ caseId: basicCase.id, subCaseId: basicSubCaseId })
);
await waitForNextUpdate();
result.current.updateCaseProperty(sampleUpdate);
await waitForNextUpdate();
expect(result.current).toEqual({
updateKey: null,
isLoading: false,
isError: false,
updateCaseProperty: result.current.updateCaseProperty,
});
expect(fetchCaseUserActions).toBeCalledWith(basicCase.id, basicSubCaseId);
expect(updateCase).toBeCalledWith(basicCase);
expect(onSuccess).toHaveBeenCalled();
});

View file

@ -5,12 +5,12 @@
* 2.0.
*/
import { useReducer, useCallback } from 'react';
import { useReducer, useCallback, useEffect, useRef } from 'react';
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
import { patchCase } from './api';
import { UpdateKey, UpdateByKey } from './types';
import { patchCase, patchSubCase } from './api';
import { UpdateKey, UpdateByKey, CaseStatuses } from './types';
import * as i18n from './translations';
import { createUpdateSuccessToaster } from './utils';
@ -57,13 +57,21 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
export interface UseUpdateCase extends NewCaseState {
updateCaseProperty: (updates: UpdateByKey) => void;
}
export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => {
export const useUpdateCase = ({
caseId,
subCaseId,
}: {
caseId: string;
subCaseId?: string;
}): UseUpdateCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
updateKey: null,
});
const [, dispatchToaster] = useStateToaster();
const abortCtrl = useRef(new AbortController());
const didCancel = useRef(false);
const dispatchUpdateCaseProperty = useCallback(
async ({
@ -75,20 +83,27 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase =>
onSuccess,
onError,
}: UpdateByKey) => {
let cancel = false;
const abortCtrl = new AbortController();
try {
didCancel.current = false;
abortCtrl.current = new AbortController();
dispatch({ type: 'FETCH_INIT', payload: updateKey });
const response = await patchCase(
caseId,
{ [updateKey]: updateValue },
caseData.version,
abortCtrl.signal
);
if (!cancel) {
const response = await (updateKey === 'status' && subCaseId
? patchSubCase(
caseId,
subCaseId,
{ status: updateValue as CaseStatuses },
caseData.version,
abortCtrl.current.signal
)
: patchCase(
caseId,
{ [updateKey]: updateValue },
caseData.version,
abortCtrl.current.signal
));
if (!didCancel.current) {
if (fetchCaseUserActions != null) {
fetchCaseUserActions(caseId);
fetchCaseUserActions(caseId, subCaseId);
}
if (updateCase != null) {
updateCase(response[0]);
@ -104,26 +119,31 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase =>
}
}
} catch (error) {
if (!cancel) {
errorToToaster({
title: i18n.ERROR_TITLE,
error: error.body && error.body.message ? new Error(error.body.message) : error,
dispatchToaster,
});
if (!didCancel.current) {
if (error.name !== 'AbortError') {
errorToToaster({
title: i18n.ERROR_TITLE,
error: error.body && error.body.message ? new Error(error.body.message) : error,
dispatchToaster,
});
}
dispatch({ type: 'FETCH_FAILURE' });
if (onError) {
onError();
}
}
}
return () => {
cancel = true;
abortCtrl.abort();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
[caseId, subCaseId]
);
useEffect(() => {
return () => {
didCancel.current = true;
abortCtrl.current.abort();
};
}, []);
return { ...state, updateCaseProperty: dispatchUpdateCaseProperty };
};

View file

@ -7,7 +7,7 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useUpdateComment, UseUpdateComment } from './use_update_comment';
import { basicCase, basicCaseCommentPatch } from './mock';
import { basicCase, basicCaseCommentPatch, basicSubCaseId } from './mock';
import * as api from './api';
jest.mock('./api');
@ -43,7 +43,7 @@ describe('useUpdateComment', () => {
});
});
it('calls patchComment with correct arguments', async () => {
it('calls patchComment with correct arguments - case', async () => {
const spyOnPatchComment = jest.spyOn(api, 'patchComment');
await act(async () => {
@ -59,7 +59,30 @@ describe('useUpdateComment', () => {
basicCase.comments[0].id,
'updated comment',
basicCase.comments[0].version,
abortCtrl.signal
abortCtrl.signal,
undefined
);
});
});
it('calls patchComment with correct arguments - sub case', async () => {
const spyOnPatchComment = jest.spyOn(api, 'patchComment');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseUpdateComment>(() =>
useUpdateComment()
);
await waitForNextUpdate();
result.current.patchComment({ ...sampleUpdate, subCaseId: basicSubCaseId });
await waitForNextUpdate();
expect(spyOnPatchComment).toBeCalledWith(
basicCase.id,
basicCase.comments[0].id,
'updated comment',
basicCase.comments[0].version,
abortCtrl.signal,
basicSubCaseId
);
});
});

View file

@ -57,6 +57,7 @@ interface UpdateComment {
commentId: string;
commentUpdate: string;
fetchUserActions: () => void;
subCaseId?: string;
updateCase: (newCase: Case) => void;
version: string;
}
@ -78,6 +79,7 @@ export const useUpdateComment = (): UseUpdateComment => {
commentId,
commentUpdate,
fetchUserActions,
subCaseId,
updateCase,
version,
}: UpdateComment) => {
@ -90,7 +92,8 @@ export const useUpdateComment = (): UseUpdateComment => {
commentId,
commentUpdate,
version,
abortCtrl.signal
abortCtrl.signal,
subCaseId
);
if (!cancel) {
updateCase(response);

View file

@ -21,7 +21,10 @@ import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/call
export const CaseDetailsPage = React.memo(() => {
const history = useHistory();
const userPermissions = useGetUserSavedObjectPermissions();
const { detailName: caseId } = useParams<{ detailName?: string }>();
const { detailName: caseId, subCaseId } = useParams<{
detailName?: string;
subCaseId?: string;
}>();
const search = useGetUrlSearch(navTabs.case);
if (userPermissions != null && !userPermissions.read) {
@ -38,7 +41,11 @@ export const CaseDetailsPage = React.memo(() => {
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
/>
)}
<CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} />
<CaseView
caseId={caseId}
subCaseId={subCaseId}
userCanCrud={userPermissions?.crud ?? false}
/>
</WrapperPage>
<SpyRoute pageName={SecurityPageName.case} />
</>

View file

@ -15,7 +15,9 @@ import { ConfigureCasesPage } from './configure_cases';
const casesPagePath = '';
const caseDetailsPagePath = `${casesPagePath}/:detailName`;
const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`;
const subCaseDetailsPagePath = `${caseDetailsPagePath}/sub-cases/:subCaseId`;
const caseDetailsPagePathWithCommentId = `${caseDetailsPagePath}/:commentId`;
const subCaseDetailsPagePathWithCommentId = `${subCaseDetailsPagePath}/:commentId`;
const createCasePagePath = `${casesPagePath}/create`;
const configureCasesPagePath = `${casesPagePath}/configure`;
@ -27,7 +29,13 @@ const CaseContainerComponent: React.FC = () => (
<Route path={configureCasesPagePath}>
<ConfigureCasesPage />
</Route>
<Route path={caseDetailsPagePathWithCommentId}>
<Route exact path={subCaseDetailsPagePathWithCommentId}>
<CaseDetailsPage />
</Route>
<Route exact path={caseDetailsPagePathWithCommentId}>
<CaseDetailsPage />
</Route>
<Route exact path={subCaseDetailsPagePath}>
<CaseDetailsPage />
</Route>
<Route path={caseDetailsPagePath}>

View file

@ -9,19 +9,43 @@ import { appendSearch } from './helpers';
export const getCaseUrl = (search?: string | null) => `${appendSearch(search ?? undefined)}`;
export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) =>
`/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`;
export const getCaseDetailsUrl = ({
id,
search,
subCaseId,
}: {
id: string;
search?: string | null;
subCaseId?: string;
}) => {
if (subCaseId) {
return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(subCaseId)}${appendSearch(
search ?? undefined
)}`;
}
return `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`;
};
export const getCaseDetailsUrlWithCommentId = ({
id,
commentId,
search,
subCaseId,
}: {
id: string;
commentId: string;
search?: string | null;
}) =>
`/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`;
subCaseId?: string;
}) => {
if (subCaseId) {
return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(
subCaseId
)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`;
}
return `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(
search ?? undefined
)}`;
};
export const getCreateCaseUrl = (search?: string | null) =>
`/create${appendSearch(search ?? undefined)}`;

View file

@ -164,24 +164,25 @@ export const NetworkDetailsLink = React.memo(NetworkDetailsLinkComponent);
const CaseDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
detailName: string;
subCaseId?: string;
title?: string;
}> = ({ children, detailName, title }) => {
}> = ({ children, detailName, subCaseId, title }) => {
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
const { navigateToApp } = useKibana().services.application;
const goToCaseDetails = useCallback(
(ev) => {
ev.preventDefault();
navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
path: getCaseDetailsUrl({ id: detailName, search }),
path: getCaseDetailsUrl({ id: detailName, search, subCaseId }),
});
},
[detailName, navigateToApp, search]
[detailName, navigateToApp, search, subCaseId]
);
return (
<LinkAnchor
onClick={goToCaseDetails}
href={formatUrl(getCaseDetailsUrl({ id: detailName }))}
href={formatUrl(getCaseDetailsUrl({ id: detailName, subCaseId }))}
data-test-subj="case-details-link"
aria-label={i18n.CASE_DETAILS_LINK_ARIA(title ?? detailName)}
>

View file

@ -421,7 +421,7 @@ describe('alert actions', () => {
...mockEcsDataWithAlert,
timestamp: '2020-03-20T17:59:46.349Z',
};
const result = determineToAndFrom({ ecsData: ecsDataMock });
const result = determineToAndFrom({ ecs: ecsDataMock });
expect(result.from).toEqual('2020-03-20T17:54:46.349Z');
expect(result.to).toEqual('2020-03-20T17:59:46.349Z');
@ -431,7 +431,7 @@ describe('alert actions', () => {
const { timestamp, ...ecsDataMock } = {
...mockEcsDataWithAlert,
};
const result = determineToAndFrom({ ecsData: ecsDataMock });
const result = determineToAndFrom({ ecs: ecsDataMock });
expect(result.from).toEqual('2020-03-01T17:54:46.349Z');
expect(result.to).toEqual('2020-03-01T17:59:46.349Z');

View file

@ -39,6 +39,7 @@ import {
} from './helpers';
import { KueryFilterQueryKind } from '../../../common/store';
import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider';
import { esFilters } from '../../../../../../../src/plugins/data/public';
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
return {
@ -102,17 +103,32 @@ export const updateAlertStatusAction = async ({
}
};
export const determineToAndFrom = ({ ecsData }: { ecsData: Ecs }) => {
export const determineToAndFrom = ({ ecs }: { ecs: Ecs[] | Ecs }) => {
if (Array.isArray(ecs)) {
const timestamps = ecs.reduce<number[]>((acc, item) => {
if (item.timestamp != null) {
const dateTimestamp = new Date(item.timestamp);
if (!acc.includes(dateTimestamp.valueOf())) {
return [...acc, dateTimestamp.valueOf()];
}
}
return acc;
}, []);
return {
from: new Date(Math.min(...timestamps)).toISOString(),
to: new Date(Math.max(...timestamps)).toISOString(),
};
}
const ecsData = ecs as Ecs;
const ellapsedTimeRule = moment.duration(
moment().diff(
dateMath.parse(ecsData.signal?.rule?.from != null ? ecsData.signal?.rule?.from[0] : 'now-0s')
dateMath.parse(ecsData?.signal?.rule?.from != null ? ecsData.signal?.rule?.from[0] : 'now-0s')
)
);
const from = moment(ecsData.timestamp ?? new Date())
const from = moment(ecsData?.timestamp ?? new Date())
.subtract(ellapsedTimeRule)
.toISOString();
const to = moment(ecsData.timestamp ?? new Date()).toISOString();
const to = moment(ecsData?.timestamp ?? new Date()).toISOString();
return { to, from };
};
@ -128,37 +144,41 @@ const getFiltersFromRule = (filters: string[]): Filter[] =>
}, [] as Filter[]);
export const getThresholdAggregationDataProvider = (
ecsData: Ecs,
ecsData: Ecs | Ecs[],
nonEcsData: TimelineNonEcsData[]
): DataProvider[] => {
const aggregationField = ecsData.signal?.rule?.threshold?.field!;
const aggregationValue =
get(aggregationField, ecsData) ?? find(['field', aggregationField], nonEcsData)?.value;
const dataProviderValue = Array.isArray(aggregationValue)
? aggregationValue[0]
: aggregationValue;
const thresholdEcsData: Ecs[] = Array.isArray(ecsData) ? ecsData : [ecsData];
return thresholdEcsData.reduce<DataProvider[]>((acc, tresholdData) => {
const aggregationField = tresholdData.signal?.rule?.threshold?.field!;
const aggregationValue =
get(aggregationField, tresholdData) ?? find(['field', aggregationField], nonEcsData)?.value;
const dataProviderValue = Array.isArray(aggregationValue)
? aggregationValue[0]
: aggregationValue;
if (!dataProviderValue) {
return [];
}
if (!dataProviderValue) {
return acc;
}
const aggregationFieldId = aggregationField.replace('.', '-');
const aggregationFieldId = aggregationField.replace('.', '-');
return [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${aggregationFieldId}-${dataProviderValue}`,
name: aggregationField,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: aggregationField,
value: dataProviderValue,
operator: ':',
return [
...acc,
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${aggregationFieldId}-${dataProviderValue}`,
name: aggregationField,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: aggregationField,
value: dataProviderValue,
operator: ':',
},
},
},
];
];
}, []);
};
export const isEqlRuleWithGroupId = (ecsData: Ecs) =>
@ -169,20 +189,134 @@ export const isEqlRuleWithGroupId = (ecsData: Ecs) =>
export const isThresholdRule = (ecsData: Ecs) =>
ecsData.signal?.rule?.type?.length && ecsData.signal?.rule?.type[0] === 'threshold';
export const buildAlertsKqlFilter = (
key: '_id' | 'signal.group.id',
alertIds: string[]
): Filter[] => {
return [
{
query: {
bool: {
filter: {
ids: {
values: alertIds,
},
},
},
},
meta: {
alias: 'Alert Ids',
negate: false,
disabled: false,
type: 'phrases',
key,
value: alertIds.join(),
params: alertIds,
},
$state: {
store: esFilters.FilterStateStore.APP_STATE,
},
},
];
};
export const buildTimelineDataProviderOrFilter = (
alertsIds: string[],
_id: string
): { filters: Filter[]; dataProviders: DataProvider[] } => {
if (!isEmpty(alertsIds)) {
return {
dataProviders: [],
filters: buildAlertsKqlFilter('_id', alertsIds),
};
}
return {
filters: [],
dataProviders: [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${_id}`,
name: _id,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: '_id',
value: _id,
operator: ':' as const,
},
},
],
};
};
export const buildEqlDataProviderOrFilter = (
alertsIds: string[],
ecs: Ecs[] | Ecs
): { filters: Filter[]; dataProviders: DataProvider[] } => {
if (!isEmpty(alertsIds) && Array.isArray(ecs)) {
return {
dataProviders: [],
filters: buildAlertsKqlFilter(
'signal.group.id',
ecs.reduce<string[]>((acc, ecsData) => {
const signalGroupId = ecsData.signal?.group?.id?.length
? ecsData.signal?.group?.id[0]
: 'unknown-signal-group-id';
if (!acc.includes(signalGroupId)) {
return [...acc, signalGroupId];
}
return acc;
}, [])
),
};
} else if (!Array.isArray(ecs)) {
const signalGroupId = ecs.signal?.group?.id?.length
? ecs.signal?.group?.id[0]
: 'unknown-signal-group-id';
return {
dataProviders: [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`,
name: ecs._id,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'signal.group.id',
value: signalGroupId,
operator: ':' as const,
},
},
],
filters: [],
};
}
return { filters: [], dataProviders: [] };
};
export const sendAlertToTimelineAction = async ({
apolloClient,
createTimeline,
ecsData,
ecsData: ecs,
nonEcsData,
updateTimelineIsLoading,
searchStrategyClient,
}: SendAlertToTimelineActionProps) => {
/* FUTURE DEVELOPER
* We are making an assumption here that if you have an array of ecs data they are all coming from the same rule
* but we still want to determine the filter for each alerts
*/
const ecsData: Ecs = Array.isArray(ecs) && ecs.length > 0 ? ecs[0] : (ecs as Ecs);
const alertIds = Array.isArray(ecs) ? ecs.map((d) => d._id) : [];
const noteContent = ecsData.signal?.rule?.note != null ? ecsData.signal?.rule?.note[0] : '';
const timelineId =
ecsData.signal?.rule?.timeline_id != null ? ecsData.signal?.rule?.timeline_id[0] : '';
const { to, from } = determineToAndFrom({ ecsData });
const { to, from } = determineToAndFrom({ ecs });
if (!isEmpty(timelineId) && apolloClient != null) {
// For now we do not want to populate the template timeline if we have alertIds
if (!isEmpty(timelineId) && apolloClient != null && isEmpty(alertIds)) {
try {
updateTimelineIsLoading({ id: TimelineId.active, isLoading: true });
const [responseTimeline, eventDataResp] = await Promise.all([
@ -275,7 +409,7 @@ export const sendAlertToTimelineAction = async ({
...timelineDefaults,
description: `_id: ${ecsData._id}`,
filters: getFiltersFromRule(ecsData.signal?.rule?.filters as string[]),
dataProviders: [...getThresholdAggregationDataProvider(ecsData, nonEcsData)],
dataProviders: [...getThresholdAggregationDataProvider(ecs, nonEcsData)],
id: TimelineId.active,
indexNames: [],
dateRange: {
@ -301,36 +435,11 @@ export const sendAlertToTimelineAction = async ({
ruleNote: noteContent,
});
} else {
let dataProviders = [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`,
name: ecsData._id,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: '_id',
value: ecsData._id,
operator: ':' as const,
},
},
];
let { dataProviders, filters } = buildTimelineDataProviderOrFilter(alertIds ?? [], ecsData._id);
if (isEqlRuleWithGroupId(ecsData)) {
const signalGroupId = ecsData.signal?.group?.id?.length
? ecsData.signal?.group?.id[0]
: 'unknown-signal-group-id';
dataProviders = [
{
...dataProviders[0],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`,
queryMatch: {
field: 'signal.group.id',
value: signalGroupId,
operator: ':' as const,
},
},
];
const tempEql = buildEqlDataProviderOrFilter(alertIds ?? [], ecs);
dataProviders = tempEql.dataProviders;
filters = tempEql.filters;
}
return createTimeline({
@ -346,6 +455,7 @@ export const sendAlertToTimelineAction = async ({
end: to,
},
eventType: 'all',
filters,
kqlQuery: {
filterQuery: {
kuery: {

View file

@ -24,14 +24,18 @@ import {
} from '../translations';
interface InvestigateInTimelineActionProps {
ariaLabel?: string;
ecsRowData: Ecs;
ecsRowData: Ecs | Ecs[] | null;
nonEcsRowData: TimelineNonEcsData[];
ariaLabel?: string;
alertIds?: string[];
fetchEcsAlertsData?: (alertIds?: string[]) => Promise<Ecs[]>;
}
const InvestigateInTimelineActionComponent: React.FC<InvestigateInTimelineActionProps> = ({
ariaLabel = ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL,
alertIds,
ecsRowData,
fetchEcsAlertsData,
nonEcsRowData,
}) => {
const {
@ -66,25 +70,42 @@ const InvestigateInTimelineActionComponent: React.FC<InvestigateInTimelineAction
[dispatch, updateTimelineIsLoading]
);
const investigateInTimelineAlertClick = useCallback(
() =>
sendAlertToTimelineAction({
apolloClient,
createTimeline,
ecsData: ecsRowData,
nonEcsData: nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
}),
[
apolloClient,
createTimeline,
ecsRowData,
nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
]
);
const investigateInTimelineAlertClick = useCallback(async () => {
try {
if (ecsRowData != null) {
await sendAlertToTimelineAction({
apolloClient,
createTimeline,
ecsData: ecsRowData,
nonEcsData: nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
});
}
if (ecsRowData == null && fetchEcsAlertsData) {
const alertsEcsData = await fetchEcsAlertsData(alertIds);
await sendAlertToTimelineAction({
apolloClient,
createTimeline,
ecsData: alertsEcsData,
nonEcsData: nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
});
}
} catch {
// TODO show a toaster that something went wrong
}
}, [
alertIds,
apolloClient,
createTimeline,
ecsRowData,
fetchEcsAlertsData,
nonEcsRowData,
searchStrategyClient,
updateTimelineIsLoading,
]);
return (
<ActionIconItem

View file

@ -55,7 +55,7 @@ export interface UpdateAlertStatusActionProps {
export interface SendAlertToTimelineActionProps {
apolloClient?: ApolloClient<{}>;
createTimeline: CreateTimeline;
ecsData: Ecs;
ecsData: Ecs | Ecs[];
nonEcsData: TimelineNonEcsData[];
updateTimelineIsLoading: UpdateTimelineLoading;
searchStrategyClient: ISearchStart;

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { isEmpty } from 'lodash';
import React, { SetStateAction, useEffect, useState } from 'react';
import { fetchQueryAlerts } from './api';
@ -80,7 +81,9 @@ export const useQueryAlerts = <Hit, Aggs>(
}
};
fetchData();
if (!isEmpty(query)) {
fetchData();
}
return () => {
isSubscribed = false;
abortCtrl.abort();

View file

@ -172,8 +172,10 @@ export const singleBulkCreate = async ({
logger.debug(buildRuleMessage(`took property says bulk took: ${response.took} milliseconds`));
const createdItems = filteredEvents.hits.hits
.map((doc) =>
buildBulkBody({
.map((doc, index) => ({
_id: response.items[index].create?._id ?? '',
_index: response.items[index].create?._index ?? '',
...buildBulkBody({
doc,
ruleParams,
id,
@ -187,8 +189,8 @@ export const singleBulkCreate = async ({
enabled,
tags,
throttle,
})
)
}),
}))
.filter((_, index) => get(response.items[index], 'create.status') === 201);
const createdItemsCount = createdItems.length;
const duplicateSignalsCount = countBy(response.items, 'create.status')['409'];
@ -263,7 +265,11 @@ export const bulkInsertSignals = async (
const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0;
const createdItems = signals
.map((doc) => doc._source)
.map((doc, index) => ({
...doc._source,
_id: response.items[index].create?._id ?? '',
_index: response.items[index].create?._index ?? '',
}))
.filter((_, index) => get(response.items[index], 'create.status') === 201);
logger.debug(`bulk created ${createdItemsCount} signals`);
return { bulkCreateDuration: makeFloatString(end - start), createdItems, createdItemsCount };

View file

@ -17250,7 +17250,6 @@
"xpack.securitySolution.case.caseView.reporterLabel": "報告者",
"xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "{ externalService }インシデントの更新が必要です",
"xpack.securitySolution.case.caseView.sendEmalLinkAria": "クリックすると、{user}に電子メールを送信します",
"xpack.securitySolution.case.caseView.showAlertDeletedTooltip": "アラートが見つかりません",
"xpack.securitySolution.case.caseView.showAlertTooltip": "アラートの詳細を表示",
"xpack.securitySolution.case.caseView.statusLabel": "ステータス",
"xpack.securitySolution.case.caseView.tags": "タグ",

View file

@ -17293,7 +17293,6 @@
"xpack.securitySolution.case.caseView.reporterLabel": "报告者",
"xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "需要更新 { externalService } 事件",
"xpack.securitySolution.case.caseView.sendEmalLinkAria": "单击可向 {user} 发送电子邮件",
"xpack.securitySolution.case.caseView.showAlertDeletedTooltip": "未找到告警",
"xpack.securitySolution.case.caseView.showAlertTooltip": "显示告警详情",
"xpack.securitySolution.case.caseView.statusLabel": "状态",
"xpack.securitySolution.case.caseView.tags": "标签",

View file

@ -95,6 +95,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: CommentType.alert,
alertId: 'test-id',
index: 'test-index',
rule: {
id: 'id',
name: 'name',
},
})
.expect(400);
});
@ -110,6 +114,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: CommentType.generatedAlert,
alerts: [{ _id: 'id1' }],
index: 'test-index',
rule: {
id: 'id',
name: 'name',
},
})
.expect(400);
});
@ -167,6 +175,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: CommentType.alert,
alertId: 'new-id',
index: postCommentAlertReq.index,
rule: {
id: 'id',
name: 'name',
},
})
.expect(200);
@ -230,6 +242,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: CommentType.alert,
alertId: 'test-id',
index: 'test-index',
rule: {
id: 'id',
name: 'name',
},
})
.expect(400);
});
@ -302,6 +318,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: CommentType.alert,
index: 'test-index',
alertId: 'test-id',
rule: {
id: 'id',
name: 'name',
},
};
for (const attribute of ['alertId', 'index']) {
@ -341,6 +361,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: CommentType.alert,
index: 'test-index',
alertId: 'test-id',
rule: {
id: 'id',
name: 'name',
},
[attribute]: attribute,
})
.expect(400);

View file

@ -148,6 +148,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: CommentType.alert,
index: 'test-index',
alertId: 'test-id',
rule: {
id: 'id',
name: 'name',
},
};
for (const attribute of ['alertId', 'index']) {
@ -176,6 +180,10 @@ export default ({ getService }: FtrProviderContext): void => {
[attribute]: attribute,
alertId: 'test-id',
index: 'test-index',
rule: {
id: 'id',
name: 'name',
},
})
.expect(400);
}
@ -296,6 +304,10 @@ export default ({ getService }: FtrProviderContext): void => {
.send({
alertId: alert._id,
index: alert._index,
rule: {
id: 'id',
name: 'name',
},
type: CommentType.alert,
})
.expect(200);
@ -346,6 +358,10 @@ export default ({ getService }: FtrProviderContext): void => {
.send({
alertId: alert._id,
index: alert._index,
rule: {
id: 'id',
name: 'name',
},
type: CommentType.alert,
})
.expect(200);

View file

@ -402,6 +402,10 @@ export default ({ getService }: FtrProviderContext): void => {
.send({
alertId: alert._id,
index: alert._index,
rule: {
id: 'id',
name: 'name',
},
type: CommentType.alert,
})
.expect(200);
@ -453,6 +457,10 @@ export default ({ getService }: FtrProviderContext): void => {
alertId: alert._id,
index: alert._index,
type: CommentType.alert,
rule: {
id: 'id',
name: 'name',
},
})
.expect(200);
@ -503,6 +511,10 @@ export default ({ getService }: FtrProviderContext): void => {
.send({
alertId: alert._id,
index: alert._index,
rule: {
id: 'id',
name: 'name',
},
type: CommentType.alert,
})
.expect(200);
@ -570,6 +582,10 @@ export default ({ getService }: FtrProviderContext): void => {
alertId: alert._id,
index: alert._index,
type: CommentType.alert,
rule: {
id: 'id',
name: 'name',
},
})
.expect(200);

View file

@ -736,7 +736,12 @@ export default ({ getService }: FtrProviderContext): void => {
subAction: 'addComment',
subActionParams: {
caseId: caseRes.body.id,
comment: { alertId: alert._id, index: alert._index, type: CommentType.alert },
comment: {
alertId: alert._id,
index: alert._index,
type: CommentType.alert,
rule: { id: 'id', name: 'name' },
},
},
};
@ -784,7 +789,12 @@ export default ({ getService }: FtrProviderContext): void => {
.expect(200);
createdActionId = createdAction.id;
const comment = { alertId: 'test-id', index: 'test-index', type: CommentType.alert };
const comment = {
alertId: 'test-id',
index: 'test-index',
type: CommentType.alert,
rule: { id: 'id', name: 'name' },
};
const params = {
subAction: 'addComment',
subActionParams: {
@ -876,7 +886,12 @@ export default ({ getService }: FtrProviderContext): void => {
subAction: 'addComment',
subActionParams: {
caseId: '123',
comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert },
comment: {
alertId: 'test-id',
index: 'test-index',
type: CommentType.alert,
rule: { id: 'id', name: 'name' },
},
},
};
@ -1017,7 +1032,12 @@ export default ({ getService }: FtrProviderContext): void => {
subAction: 'addComment',
subActionParams: {
caseId: caseRes.body.id,
comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert },
comment: {
alertId: 'test-id',
index: 'test-index',
type: CommentType.alert,
rule: { id: 'id', name: 'name' },
},
},
};

View file

@ -8,6 +8,7 @@
import {
CommentSchemaType,
ContextTypeGeneratedAlertType,
createAlertsString,
isCommentGeneratedAlert,
transformConnectorComment,
} from '../../../../plugins/case/server/connectors';
@ -70,12 +71,15 @@ export const postCommentUserReq: CommentRequestUserType = {
export const postCommentAlertReq: CommentRequestAlertType = {
alertId: 'test-id',
index: 'test-index',
rule: { id: 'test-rule-id', name: 'test-index-id' },
type: CommentType.alert,
};
export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = {
alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }],
index: 'test-index',
alerts: createAlertsString([
{ _id: 'test-id', _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' },
{ _id: 'test-id2', _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' },
]),
type: CommentType.generatedAlert,
};