[Cases] Include rule registry client for updating alert statuses (#108588)

* Trying to get import to work

* Plumbed alerts client through and logging errors

* No longer need the ES cluster client

* Fixing types

* Fixing imports

* Fixing integration tests and refactoring

* Throwing an error when rule registry is disabled

* Reworking alert update and get to catch errors

* Adding tests and fixing errors
This commit is contained in:
Jonathan Buttner 2021-08-19 16:01:39 -04:00 committed by GitHub
parent 7369bdf360
commit 1fd7038b34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 5508 additions and 256 deletions

View file

@ -10,6 +10,7 @@
"id":"cases",
"kibanaVersion":"kibana",
"optionalPlugins":[
"ruleRegistry",
"security",
"spaces"
],

View file

@ -12,19 +12,11 @@ export const get = async (
{ alertsInfo }: AlertGet,
clientArgs: CasesClientArgs
): Promise<CasesClientGetAlertsResponse> => {
const { alertsService, scopedClusterClient, logger } = clientArgs;
const { alertsService, logger } = clientArgs;
if (alertsInfo.length === 0) {
return [];
}
const alerts = await alertsService.getAlerts({ alertsInfo, scopedClusterClient, logger });
if (!alerts) {
return [];
}
return alerts.docs.map((alert) => ({
id: alert._id,
index: alert._index,
...alert._source,
}));
const alerts = await alertsService.getAlerts({ alertsInfo, logger });
return alerts ?? [];
};

View file

@ -7,17 +7,7 @@
import { CaseStatuses } from '../../../common/api';
import { AlertInfo } from '../../common';
interface Alert {
id: string;
index: string;
destination?: {
ip: string;
};
source?: {
ip: string;
};
}
import { Alert } from '../../services/alerts/types';
export type CasesClientGetAlertsResponse = Alert[];

View file

@ -16,6 +16,6 @@ export const updateStatus = async (
{ alerts }: UpdateAlertsStatusArgs,
clientArgs: CasesClientArgs
): Promise<void> => {
const { alertsService, scopedClusterClient, logger } = clientArgs;
await alertsService.updateAlertsStatus({ alerts, scopedClusterClient, logger });
const { alertsService, logger } = clientArgs;
await alertsService.updateAlertsStatus({ alerts, logger });
};

View file

@ -40,12 +40,7 @@ import {
} from '../../services/user_actions/helpers';
import { AttachmentService, CasesService, CaseUserActionService } from '../../services';
import {
createCaseError,
CommentableCase,
createAlertUpdateRequest,
isCommentRequestTypeGenAlert,
} from '../../common';
import { createCaseError, CommentableCase, isCommentRequestTypeGenAlert } from '../../common';
import { CasesClientArgs, CasesClientInternal } from '..';
import { decodeCommentRequest } from '../utils';
@ -195,22 +190,9 @@ const addGeneratedAlerts = async (
user: userDetails,
commentReq: query,
id: savedObjectID,
casesClientInternal,
});
if (
(newComment.attributes.type === CommentType.alert ||
newComment.attributes.type === CommentType.generatedAlert) &&
caseInfo.attributes.settings.syncAlerts
) {
const alertsToUpdate = createAlertUpdateRequest({
comment: query,
status: subCase.attributes.status,
});
await casesClientInternal.alerts.updateStatus({
alerts: alertsToUpdate,
});
}
await userActionService.bulkCreate({
unsecuredSavedObjectsClient,
actions: [
@ -386,19 +368,9 @@ export const addComment = async (
user: userInfo,
commentReq: query,
id: savedObjectID,
casesClientInternal,
});
if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) {
const alertsToUpdate = createAlertUpdateRequest({
comment: query,
status: updatedCase.status,
});
await casesClientInternal.alerts.updateStatus({
alerts: alertsToUpdate,
});
}
await userActionService.bulkCreate({
unsecuredSavedObjectsClient,
actions: [

View file

@ -6,7 +6,7 @@
*/
import Boom from '@hapi/boom';
import { SavedObjectsFindResponse, SavedObject } from 'kibana/server';
import { SavedObjectsFindResponse, SavedObject, Logger } from 'kibana/server';
import {
ActionConnector,
@ -22,26 +22,16 @@ import {
import { buildCaseUserActionItem } from '../../services/user_actions/helpers';
import { createIncident, getCommentContextFromAttributes } from './utils';
import { createCaseError, flattenCaseSavedObject, getAlertInfoFromComments } from '../../common';
import {
AlertInfo,
createCaseError,
flattenCaseSavedObject,
getAlertInfoFromComments,
} from '../../common';
import { CasesClient, CasesClientArgs, CasesClientInternal } from '..';
import { Operations } from '../../authorization';
import { casesConnectors } from '../../connectors';
/**
* Returns true if the case should be closed based on the configuration settings and whether the case
* is a collection. Collections are not closable because we aren't allowing their status to be changed.
* In the future we could allow push to close all the sub cases of a collection but that's not currently supported.
*/
function shouldCloseByPush(
configureSettings: SavedObjectsFindResponse<CasesConfigureAttributes>,
caseInfo: SavedObject<CaseAttributes>
): boolean {
return (
configureSettings.total > 0 &&
configureSettings.saved_objects[0].attributes.closure_type === 'close-by-pushing' &&
caseInfo.attributes.type !== CaseType.collection
);
}
import { CasesClientGetAlertsResponse } from '../alerts/types';
/**
* Parameters for pushing a case to an external system
@ -106,9 +96,7 @@ export const push = async (
const alertsInfo = getAlertInfoFromComments(theCase?.comments);
const alerts = await casesClientInternal.alerts.get({
alertsInfo,
});
const alerts = await getAlertsCatchErrors({ casesClientInternal, alertsInfo, logger });
const getMappingsResponse = await casesClientInternal.configuration.getMappings({
connector: theCase.connector,
@ -278,3 +266,38 @@ export const push = async (
throw createCaseError({ message: `Failed to push case: ${error}`, error, logger });
}
};
async function getAlertsCatchErrors({
casesClientInternal,
alertsInfo,
logger,
}: {
casesClientInternal: CasesClientInternal;
alertsInfo: AlertInfo[];
logger: Logger;
}): Promise<CasesClientGetAlertsResponse> {
try {
return await casesClientInternal.alerts.get({
alertsInfo,
});
} catch (error) {
logger.error(`Failed to retrieve alerts during push: ${error}`);
return [];
}
}
/**
* Returns true if the case should be closed based on the configuration settings and whether the case
* is a collection. Collections are not closable because we aren't allowing their status to be changed.
* In the future we could allow push to close all the sub cases of a collection but that's not currently supported.
*/
function shouldCloseByPush(
configureSettings: SavedObjectsFindResponse<CasesConfigureAttributes>,
caseInfo: SavedObject<CaseAttributes>
): boolean {
return (
configureSettings.total > 0 &&
configureSettings.saved_objects[0].attributes.closure_type === 'close-by-pushing' &&
caseInfo.attributes.type !== CaseType.collection
);
}

View file

@ -12,6 +12,7 @@ import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import {
Logger,
SavedObject,
SavedObjectsClientContract,
SavedObjectsFindResponse,
@ -307,12 +308,14 @@ async function updateAlerts({
caseService,
unsecuredSavedObjectsClient,
casesClientInternal,
logger,
}: {
casesWithSyncSettingChangedToOn: UpdateRequestWithOriginalCase[];
casesWithStatusChangedAndSynced: UpdateRequestWithOriginalCase[];
caseService: CasesService;
unsecuredSavedObjectsClient: SavedObjectsClientContract;
casesClientInternal: CasesClientInternal;
logger: Logger;
}) {
/**
* It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes
@ -361,7 +364,9 @@ async function updateAlerts({
[]
);
await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate });
await casesClientInternal.alerts.updateStatus({
alerts: alertsToUpdate,
});
}
function partitionPatchRequest(
@ -562,15 +567,6 @@ export const update = async (
);
});
// Update the alert's status to match any case status or sync settings changes
await updateAlerts({
casesWithStatusChangedAndSynced,
casesWithSyncSettingChangedToOn,
caseService,
unsecuredSavedObjectsClient,
casesClientInternal,
});
const returnUpdatedCase = myCases.saved_objects
.filter((myCase) =>
updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id)
@ -598,6 +594,17 @@ export const update = async (
}),
});
// Update the alert's status to match any case status or sync settings changes
// Attempt to do this after creating/changing the other entities just in case it fails
await updateAlerts({
casesWithStatusChangedAndSynced,
casesWithSyncSettingChangedToOn,
caseService,
unsecuredSavedObjectsClient,
casesClientInternal,
logger,
});
return CasesResponseRt.encode(returnUpdatedCase);
} catch (error) {
const idVersions = cases.cases.map((caseInfo) => ({

View file

@ -5,12 +5,7 @@
* 2.0.
*/
import {
KibanaRequest,
SavedObjectsServiceStart,
Logger,
ElasticsearchClient,
} from 'kibana/server';
import { KibanaRequest, SavedObjectsServiceStart, Logger } from 'kibana/server';
import { SecurityPluginSetup, SecurityPluginStart } from '../../../security/server';
import { SAVED_OBJECT_TYPES } from '../../common';
import { Authorization } from '../authorization/authorization';
@ -25,8 +20,8 @@ import {
} from '../services';
import { PluginStartContract as FeaturesPluginStart } from '../../../features/server';
import { PluginStartContract as ActionsPluginStart } from '../../../actions/server';
import { RuleRegistryPluginStartContract } from '../../../rule_registry/server';
import { LensServerPluginSetup } from '../../../lens/server';
import { AuthorizationAuditLogger } from '../authorization';
import { CasesClient, createCasesClient } from '.';
@ -36,6 +31,7 @@ interface CasesClientFactoryArgs {
getSpace: GetSpaceFn;
featuresPluginStart: FeaturesPluginStart;
actionsPluginStart: ActionsPluginStart;
ruleRegistryPluginStart?: RuleRegistryPluginStartContract;
lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory'];
}
@ -69,12 +65,10 @@ export class CasesClientFactory {
*/
public async create({
request,
scopedClusterClient,
savedObjectsService,
}: {
request: KibanaRequest;
savedObjectsService: SavedObjectsServiceStart;
scopedClusterClient: ElasticsearchClient;
}): Promise<CasesClient> {
if (!this.isInitialized || !this.options) {
throw new Error('CasesClientFactory must be initialized before calling create');
@ -94,9 +88,12 @@ export class CasesClientFactory {
const caseService = new CasesService(this.logger, this.options?.securityPluginStart?.authc);
const userInfo = caseService.getUser({ request });
const alertsClient = await this.options.ruleRegistryPluginStart?.getRacClientWithRequest(
request
);
return createCasesClient({
alertsService: new AlertService(),
scopedClusterClient,
alertsService: new AlertService(alertsClient),
unsecuredSavedObjectsClient: savedObjectsService.getScopedClient(request, {
includedHiddenTypes: SAVED_OBJECT_TYPES,
// this tells the security plugin to not perform SO authorization and audit logging since we are handling

View file

@ -246,7 +246,9 @@ async function updateAlerts({
[]
);
await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate });
await casesClientInternal.alerts.updateStatus({
alerts: alertsToUpdate,
});
} catch (error) {
throw createCaseError({
message: `Failed to update alert status while updating sub cases: ${JSON.stringify(
@ -355,14 +357,6 @@ export async function update({
);
});
await updateAlerts({
caseService,
unsecuredSavedObjectsClient,
casesClientInternal,
subCasesToSync: subCasesToSyncAlertsFor,
logger: clientArgs.logger,
});
const returnUpdatedSubCases = updatedCases.saved_objects.reduce<SubCaseResponse[]>(
(acc, updatedSO) => {
const originalSubCase = subCasesMap.get(updatedSO.id);
@ -394,6 +388,15 @@ export async function update({
}),
});
// attempt to update the status of the alerts after creating all the user actions just in case it fails
await updateAlerts({
caseService,
unsecuredSavedObjectsClient,
casesClientInternal,
subCasesToSync: subCasesToSyncAlertsFor,
logger: clientArgs.logger,
});
return SubCasesResponseRt.encode(returnUpdatedSubCases);
} catch (error) {
const idVersions = query.subCases.map((subCase) => ({

View file

@ -6,7 +6,7 @@
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server';
import { SavedObjectsClientContract, Logger } from 'kibana/server';
import { User } from '../../common';
import { Authorization } from '../authorization/authorization';
import {
@ -24,7 +24,6 @@ import { LensServerPluginSetup } from '../../../lens/server';
* Parameters for initializing a cases client
*/
export interface CasesClientArgs {
readonly scopedClusterClient: ElasticsearchClient;
readonly caseConfigureService: CaseConfigureService;
readonly caseService: CasesService;
readonly connectorMappingsService: ConnectorMappingsService;

View file

@ -34,10 +34,16 @@ import {
CommentRequestUserType,
CaseAttributes,
} from '../../../common';
import { flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment } from '..';
import {
createAlertUpdateRequest,
flattenCommentSavedObjects,
flattenSubCaseSavedObject,
transformNewComment,
} from '..';
import { AttachmentService, CasesService } from '../../services';
import { createCaseError } from '../error';
import { countAlertsForID } from '../index';
import { CasesClientInternal } from '../../client';
import { getOrUpdateLensReferences } from '../utils';
interface UpdateCommentResp {
@ -273,11 +279,13 @@ export class CommentableCase {
user,
commentReq,
id,
casesClientInternal,
}: {
createdDate: string;
user: User;
commentReq: CommentRequest;
id: string;
casesClientInternal: CasesClientInternal;
}): Promise<NewCommentResp> {
try {
if (commentReq.type === CommentType.alert) {
@ -294,6 +302,10 @@ export class CommentableCase {
throw Boom.badRequest('The owner field of the comment must match the case');
}
// Let's try to sync the alert's status before creating the attachment, that way if the alert doesn't exist
// we'll throw an error early before creating the attachment
await this.syncAlertStatus(commentReq, casesClientInternal);
let references = this.buildRefsToCase();
if (commentReq.type === CommentType.user && commentReq?.comment) {
@ -331,6 +343,26 @@ export class CommentableCase {
}
}
private async syncAlertStatus(
commentRequest: CommentRequest,
casesClientInternal: CasesClientInternal
) {
if (
(commentRequest.type === CommentType.alert ||
commentRequest.type === CommentType.generatedAlert) &&
this.settings.syncAlerts
) {
const alertsToUpdate = createAlertUpdateRequest({
comment: commentRequest,
status: this.status,
});
await casesClientInternal.alerts.updateStatus({
alerts: alertsToUpdate,
});
}
}
private formatCollectionForEncoding(totalComment: number) {
return {
id: this.collection.id,

View file

@ -24,7 +24,7 @@ describe('ITSM formatter', () => {
} as CaseResponse;
it('it formats correctly without alerts', async () => {
const res = await format(theCase, []);
const res = format(theCase, []);
expect(res).toEqual({
dest_ip: null,
source_ip: null,
@ -38,7 +38,7 @@ describe('ITSM formatter', () => {
it('it formats correctly when fields do not exist ', async () => {
const invalidFields = { connector: { fields: null } } as CaseResponse;
const res = await format(invalidFields, []);
const res = format(invalidFields, []);
expect(res).toEqual({
dest_ip: null,
source_ip: null,
@ -55,25 +55,31 @@ describe('ITSM formatter', () => {
{
id: 'alert-1',
index: 'index-1',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
source: {
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com' },
},
url: { full: 'https://attack.com' },
},
{
id: 'alert-2',
index: 'index-2',
destination: { ip: '192.168.1.4' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' },
source: {
source: {
ip: '192.168.1.3',
},
destination: { ip: '192.168.1.4' },
file: {
hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' },
},
url: { full: 'https://attack.com/api' },
},
url: { full: 'https://attack.com/api' },
},
];
const res = await format(theCase, alerts);
const res = format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.1,192.168.1.4',
source_ip: '192.168.1.2,192.168.1.3',
@ -86,30 +92,109 @@ describe('ITSM formatter', () => {
});
});
it('it ignores alerts with an error', async () => {
const alerts = [
{
id: 'alert-1',
index: 'index-1',
error: new Error('an error'),
source: {
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com' },
},
},
{
id: 'alert-2',
index: 'index-2',
source: {
source: {
ip: '192.168.1.3',
},
destination: { ip: '192.168.1.4' },
file: {
hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' },
},
url: { full: 'https://attack.com/api' },
},
},
];
const res = format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.4',
source_ip: '192.168.1.3',
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752',
malware_url: 'https://attack.com/api',
priority: '2 - High',
});
});
it('it ignores alerts without a source field', async () => {
const alerts = [
{
id: 'alert-1',
index: 'index-1',
},
{
id: 'alert-2',
index: 'index-2',
source: {
source: {
ip: '192.168.1.3',
},
destination: { ip: '192.168.1.4' },
file: {
hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' },
},
url: { full: 'https://attack.com/api' },
},
},
];
const res = format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.4',
source_ip: '192.168.1.3',
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752',
malware_url: 'https://attack.com/api',
priority: '2 - High',
});
});
it('it handles duplicates correctly', async () => {
const alerts = [
{
id: 'alert-1',
index: 'index-1',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
source: {
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com' },
},
url: { full: 'https://attack.com' },
},
{
id: 'alert-2',
index: 'index-2',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
source: {
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com/api' },
},
url: { full: 'https://attack.com/api' },
},
];
const res = await format(theCase, alerts);
const res = format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.1',
source_ip: '192.168.1.2,192.168.1.3',
@ -126,22 +211,26 @@ describe('ITSM formatter', () => {
{
id: 'alert-1',
index: 'index-1',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
source: {
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com' },
},
url: { full: 'https://attack.com' },
},
{
id: 'alert-2',
index: 'index-2',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
source: {
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com/api' },
},
url: { full: 'https://attack.com/api' },
},
];
@ -150,7 +239,7 @@ describe('ITSM formatter', () => {
connector: { fields: { ...theCase.connector.fields, destIp: false, malwareHash: false } },
} as CaseResponse;
const res = await format(newCase, alerts);
const res = format(newCase, alerts);
expect(res).toEqual({
dest_ip: null,
source_ip: '192.168.1.2,192.168.1.3',

View file

@ -44,23 +44,25 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => {
);
if (fieldsToAdd.length > 0) {
sirFields = alerts.reduce<Record<SirFieldKey, string | null>>((acc, alert) => {
fieldsToAdd.forEach((alertField) => {
const field = get(alertFieldMapping[alertField].alertPath, alert);
if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) {
manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field);
acc = {
...acc,
[alertFieldMapping[alertField].sirFieldKey]: `${
acc[alertFieldMapping[alertField].sirFieldKey] != null
? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}`
: field
}`,
};
}
});
return acc;
}, sirFields);
sirFields = alerts
.filter((alert) => !alert.error && alert.source != null)
.reduce<Record<SirFieldKey, string | null>>((acc, alert) => {
fieldsToAdd.forEach((alertField) => {
const field = get(alertFieldMapping[alertField].alertPath, alert.source);
if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) {
manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field);
acc = {
...acc,
[alertFieldMapping[alertField].sirFieldKey]: `${
acc[alertFieldMapping[alertField].sirFieldKey] != null
? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}`
: field
}`,
};
}
});
return acc;
}, sirFields);
}
return {

View file

@ -32,6 +32,7 @@ import type { CasesRequestHandlerContext } from './types';
import { CasesClientFactory } from './client/factory';
import { SpacesPluginStart } from '../../spaces/server';
import { PluginStartContract as FeaturesPluginStart } from '../../features/server';
import { RuleRegistryPluginStartContract } from '../../rule_registry/server';
import { LensServerPluginSetup } from '../../lens/server';
function createConfig(context: PluginInitializerContext) {
@ -49,6 +50,7 @@ export interface PluginsStart {
features: FeaturesPluginStart;
spaces?: SpacesPluginStart;
actions: ActionsPluginStart;
ruleRegistry?: RuleRegistryPluginStartContract;
}
/**
@ -137,15 +139,13 @@ export class CasePlugin {
},
featuresPluginStart: plugins.features,
actionsPluginStart: plugins.actions,
ruleRegistryPluginStart: plugins.ruleRegistry,
lensEmbeddableFactory: this.lensEmbeddableFactory!,
});
const client = core.elasticsearch.client;
const getCasesClientWithRequest = async (request: KibanaRequest): Promise<CasesClient> => {
return this.clientFactory.create({
request,
scopedClusterClient: client.asScoped(request).asCurrentUser,
savedObjectsService: core.savedObjects,
});
};
@ -171,7 +171,6 @@ export class CasePlugin {
return this.clientFactory.create({
request,
scopedClusterClient: context.core.elasticsearch.client.asCurrentUser,
savedObjectsService: savedObjects,
});
},

View file

@ -5,56 +5,75 @@
* 2.0.
*/
import { KibanaRequest } from 'kibana/server';
import { CaseStatuses } from '../../../common';
import { AlertService, AlertServiceContract } from '.';
import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks';
import { loggingSystemMock } from 'src/core/server/mocks';
import { ruleRegistryMocks } from '../../../../rule_registry/server/mocks';
import { AlertsClient } from '../../../../rule_registry/server';
import { PublicMethodsOf } from '@kbn/utility-types';
describe('updateAlertsStatus', () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient();
const logger = loggingSystemMock.create().get('case');
let alertsClient: jest.Mocked<PublicMethodsOf<AlertsClient>>;
let alertService: AlertServiceContract;
beforeEach(async () => {
alertsClient = ruleRegistryMocks.createAlertsClientMock.create();
alertService = new AlertService(alertsClient);
jest.restoreAllMocks();
});
describe('happy path', () => {
let alertService: AlertServiceContract;
const args = {
alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: CaseStatuses.closed }],
request: {} as KibanaRequest,
scopedClusterClient: esClient,
logger,
};
beforeEach(async () => {
alertService = new AlertService();
jest.restoreAllMocks();
});
test('it update the status of the alert correctly', async () => {
it('updates the status of the alert correctly', async () => {
await alertService.updateAlertsStatus(args);
expect(esClient.bulk).toHaveBeenCalledWith({
body: [
{ update: { _id: 'alert-id-1', _index: '.siem-signals' } },
{
doc: {
signal: {
status: CaseStatuses.closed,
},
},
},
],
expect(alertsClient.update).toHaveBeenCalledWith({
id: 'alert-id-1',
index: '.siem-signals',
status: CaseStatuses.closed,
});
});
describe('unhappy path', () => {
it('ignores empty indices', async () => {
expect(
await alertService.updateAlertsStatus({
alerts: [{ id: 'alert-id-1', index: '', status: CaseStatuses.closed }],
scopedClusterClient: esClient,
logger,
})
).toBeUndefined();
it('translates the in-progress status to acknowledged', async () => {
await alertService.updateAlertsStatus({
alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: CaseStatuses['in-progress'] }],
logger,
});
expect(alertsClient.update).toHaveBeenCalledWith({
id: 'alert-id-1',
index: '.siem-signals',
status: 'acknowledged',
});
});
it('defaults an unknown status to open', async () => {
await alertService.updateAlertsStatus({
alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: 'bananas' as CaseStatuses }],
logger,
});
expect(alertsClient.update).toHaveBeenCalledWith({
id: 'alert-id-1',
index: '.siem-signals',
status: 'open',
});
});
});
describe('unhappy path', () => {
it('ignores empty indices', async () => {
expect(
await alertService.updateAlertsStatus({
alerts: [{ id: 'alert-id-1', index: '', status: CaseStatuses.closed }],
logger,
})
).toBeUndefined();
});
});
});

View file

@ -9,56 +9,67 @@ import { isEmpty } from 'lodash';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { ElasticsearchClient, Logger } from 'kibana/server';
import { MAX_ALERTS_PER_SUB_CASE } from '../../../common';
import { Logger } from 'kibana/server';
import { CaseStatuses, MAX_ALERTS_PER_SUB_CASE } from '../../../common';
import { AlertInfo, createCaseError } from '../../common';
import { UpdateAlertRequest } from '../../client/alerts/types';
import { AlertsClient } from '../../../../rule_registry/server';
import { Alert } from './types';
import { STATUS_VALUES } from '../../../../rule_registry/common/technical_rule_data_field_names';
export type AlertServiceContract = PublicMethodsOf<AlertService>;
interface UpdateAlertsStatusArgs {
alerts: UpdateAlertRequest[];
scopedClusterClient: ElasticsearchClient;
logger: Logger;
}
interface GetAlertsArgs {
alertsInfo: AlertInfo[];
scopedClusterClient: ElasticsearchClient;
logger: Logger;
}
interface Alert {
_id: string;
_index: string;
_source: Record<string, unknown>;
}
interface AlertsResponse {
docs: Alert[];
}
function isEmptyAlert(alert: AlertInfo): boolean {
return isEmpty(alert.id) || isEmpty(alert.index);
}
export class AlertService {
constructor() {}
constructor(private readonly alertsClient?: PublicMethodsOf<AlertsClient>) {}
public async updateAlertsStatus({ alerts, scopedClusterClient, logger }: UpdateAlertsStatusArgs) {
public async updateAlertsStatus({ alerts, logger }: UpdateAlertsStatusArgs) {
try {
const body = alerts
.filter((alert) => !isEmptyAlert(alert))
.flatMap((alert) => [
{ update: { _id: alert.id, _index: alert.index } },
{ doc: { signal: { status: alert.status } } },
]);
if (!this.alertsClient) {
throw new Error(
'Alert client is undefined, the rule registry plugin must be enabled to updated the status of alerts'
);
}
if (body.length <= 0) {
const alertsToUpdate = alerts.filter((alert) => !isEmptyAlert(alert));
if (alertsToUpdate.length <= 0) {
return;
}
return scopedClusterClient.bulk({ body });
const updatedAlerts = await Promise.allSettled(
alertsToUpdate.map((alert) =>
this.alertsClient?.update({
id: alert.id,
index: alert.index,
status: translateStatus({ alert, logger }),
_version: undefined,
})
)
);
updatedAlerts.forEach((updatedAlert, index) => {
if (updatedAlert.status === 'rejected') {
logger.error(
`Failed to update status for alert: ${JSON.stringify(alertsToUpdate[index])}: ${
updatedAlert.reason
}`
);
}
});
} catch (error) {
throw createCaseError({
message: `Failed to update alert status ids: ${JSON.stringify(alerts)}: ${error}`,
@ -68,25 +79,51 @@ export class AlertService {
}
}
public async getAlerts({
scopedClusterClient,
alertsInfo,
logger,
}: GetAlertsArgs): Promise<AlertsResponse | undefined> {
public async getAlerts({ alertsInfo, logger }: GetAlertsArgs): Promise<Alert[] | undefined> {
try {
const docs = alertsInfo
.filter((alert) => !isEmptyAlert(alert))
.slice(0, MAX_ALERTS_PER_SUB_CASE)
.map((alert) => ({ _id: alert.id, _index: alert.index }));
if (!this.alertsClient) {
throw new Error(
'Alert client is undefined, the rule registry plugin must be enabled to retrieve alerts'
);
}
if (docs.length <= 0) {
const alertsToGet = alertsInfo
.filter((alert) => !isEmpty(alert))
.slice(0, MAX_ALERTS_PER_SUB_CASE);
if (alertsToGet.length <= 0) {
return;
}
const results = await scopedClusterClient.mget<Alert>({ body: { docs } });
const retrievedAlerts = await Promise.allSettled(
alertsToGet.map(({ id, index }) => this.alertsClient?.get({ id, index }))
);
// @ts-expect-error @elastic/elasticsearch _source is optional
return results.body;
retrievedAlerts.forEach((alert, index) => {
if (alert.status === 'rejected') {
logger.error(
`Failed to retrieve alert: ${JSON.stringify(alertsToGet[index])}: ${alert.reason}`
);
}
});
return retrievedAlerts.map((alert, index) => {
let source: unknown | undefined;
let error: Error | undefined;
if (alert.status === 'fulfilled') {
source = alert.value;
} else {
error = alert.reason;
}
return {
id: alertsToGet[index].id,
index: alertsToGet[index].index,
source,
error,
};
});
} catch (error) {
throw createCaseError({
message: `Failed to retrieve alerts ids: ${JSON.stringify(alertsInfo)}: ${error}`,
@ -96,3 +133,27 @@ export class AlertService {
}
}
}
function translateStatus({
alert,
logger,
}: {
alert: UpdateAlertRequest;
logger: Logger;
}): STATUS_VALUES {
const translatedStatuses: Record<string, STATUS_VALUES> = {
[CaseStatuses.open]: 'open',
[CaseStatuses['in-progress']]: 'acknowledged',
[CaseStatuses.closed]: 'closed',
};
const translatedStatus = translatedStatuses[alert.status];
if (!translatedStatus) {
logger.error(
`Unable to translate case status ${alert.status} during alert update: ${JSON.stringify(
alert
)}`
);
}
return translatedStatus ?? 'open';
}

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export interface Alert {
id: string;
index: string;
error?: Error;
source?: unknown;
}

View file

@ -22,6 +22,7 @@
// Required from './kibana.json'
{ "path": "../actions/tsconfig.json" },
{ "path": "../rule_registry/tsconfig.json" },
{ "path": "../triggers_actions_ui/tsconfig.json"},
{ "path": "../../../src/plugins/es_ui_shared/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },

View file

@ -29,6 +29,7 @@ export {
} from './utils/create_lifecycle_executor';
export { createPersistenceRuleTypeFactory } from './utils/create_persistence_rule_type_factory';
export * from './utils/persistence_types';
export type { AlertsClient } from './alert_data_client/alerts_client';
export const plugin = (initContext: PluginInitializerContext) =>
new RuleRegistryPlugin(initContext);

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import { alertsClientMock } from './alert_data_client/alerts_client.mock';
import { ruleDataPluginServiceMock } from './rule_data_plugin_service/rule_data_plugin_service.mock';
import { createLifecycleAlertServicesMock } from './utils/lifecycle_alert_services_mock';
export const ruleRegistryMocks = {
createLifecycleAlertServices: createLifecycleAlertServicesMock,
createRuleDataPluginService: ruleDataPluginServiceMock.create,
createAlertsClientMock: alertsClientMock,
};

View file

@ -535,8 +535,8 @@ export default ({ getService }: FtrProviderContext): void => {
});
it('should update the status of multiple alerts attached to multiple cases', async () => {
const signalID = '5f2b9ec41f8febb1c06b5d1045aeabb9874733b7617e88a370510f2fb3a41a5d';
const signalID2 = '4d0f4b1533e46b66b43bdd0330d23f39f2cf42a7253153270e38d30cce9ff0c6';
const signalID = '4679431ee0ba3209b6fcd60a255a696886fe0a7d18f5375de510ff5b68fa6b78';
const signalID2 = '1023bcfea939643c5e51fd8df53797e0ea693cee547db579ab56d96402365c1e';
// does NOT updates alert status when adding comments and syncAlerts=false
const individualCase1 = await createCase(supertest, {
@ -653,7 +653,7 @@ export default ({ getService }: FtrProviderContext): void => {
CaseStatuses.closed
);
expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be(
CaseStatuses['in-progress']
'acknowledged'
);
});
});
@ -846,7 +846,7 @@ export default ({ getService }: FtrProviderContext): void => {
.send(getQuerySignalIds([alert._id]))
.expect(200);
expect(updatedAlert.hits.hits[0]._source?.signal.status).eql('in-progress');
expect(updatedAlert.hits.hits[0]._source?.signal.status).eql('acknowledged');
});
it('does NOT updates alert status when the status is updated and syncAlerts=false', async () => {
@ -970,7 +970,7 @@ export default ({ getService }: FtrProviderContext): void => {
.send(getQuerySignalIds([alert._id]))
.expect(200);
expect(updatedAlert.hits.hits[0]._source?.signal.status).eql('in-progress');
expect(updatedAlert.hits.hits[0]._source?.signal.status).eql('acknowledged');
});
it('it does NOT updates alert status when syncAlerts is turned off', async () => {

View file

@ -35,8 +35,8 @@ export default ({ getService }: FtrProviderContext): void => {
});
it('should update the status of multiple alerts attached to multiple cases using the cases client', async () => {
const signalID = '5f2b9ec41f8febb1c06b5d1045aeabb9874733b7617e88a370510f2fb3a41a5d';
const signalID2 = '4d0f4b1533e46b66b43bdd0330d23f39f2cf42a7253153270e38d30cce9ff0c6';
const signalID = '4679431ee0ba3209b6fcd60a255a696886fe0a7d18f5375de510ff5b68fa6b78';
const signalID2 = '1023bcfea939643c5e51fd8df53797e0ea693cee547db579ab56d96402365c1e';
// does NOT updates alert status when adding comments and syncAlerts=false
const individualCase1 = await createCase(supertest, {
@ -160,7 +160,7 @@ export default ({ getService }: FtrProviderContext): void => {
CaseStatuses.closed
);
expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be(
CaseStatuses['in-progress']
'acknowledged'
);
});
});

View file

@ -394,7 +394,7 @@ export default ({ getService }: FtrProviderContext): void => {
.send(getQuerySignalIds([alert._id]))
.expect(200);
expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress');
expect(updatedAlert.hits.hits[0]._source.signal.status).eql('acknowledged');
});
it('should NOT change the status of the alert if sync alert is off', async () => {

View file

@ -129,7 +129,7 @@ export default function ({ getService }: FtrProviderContext) {
signals = await getSignalsWithES({ es, indices: defaultSignalsIndex, ids: signalID });
expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be(
CaseStatuses['in-progress']
'acknowledged'
);
});
@ -200,7 +200,7 @@ export default function ({ getService }: FtrProviderContext) {
CaseStatuses['in-progress']
);
expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be(
CaseStatuses['in-progress']
'acknowledged'
);
});
@ -321,7 +321,7 @@ export default function ({ getService }: FtrProviderContext) {
CaseStatuses.closed
);
expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be(
CaseStatuses['in-progress']
'acknowledged'
);
});
@ -470,7 +470,7 @@ export default function ({ getService }: FtrProviderContext) {
// alerts should be updated now that the
expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be(
CaseStatuses['in-progress']
'acknowledged'
);
expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be(
CaseStatuses.closed