[Security Solutions] Critical bug fix to make error messages about missing connections clearer for the end user. (#116490) (#116803)

## Summary

Fixes issue see on this comment:
https://github.com/elastic/kibana/issues/116336#issuecomment-952159636

* Removes legacy toaster component
* Adds newer toaster component
* Removes issue with the deps array within ReactJS
* Adds utility to give a better network error message to the end user.
* This does effect the timeline component since it shares the same import common component.
* Adds a count of how many rules/timeline items have failed imports
* These error toasters mimic Kibana core's error toaster error message and UI/UX
* Adds e2e tests for imports with actions and error messages for them.

## Rules import error messages now

Before for small toaster:
<img width="417" alt="Screen Shot 2021-10-26 at 6 03 25 PM" src="https://user-images.githubusercontent.com/1151048/139132586-3cf77c73-53ac-4066-b01f-2e91ef2da111.png">

After for small toaster for different error conditions:
<img width="358" alt="Screen Shot 2021-10-26 at 6 00 24 PM" src="https://user-images.githubusercontent.com/1151048/139132679-2eeb1ed3-9f6e-4766-a8ed-8804ce3e6963.png">

<img width="396" alt="Screen Shot 2021-10-26 at 6 01 00 PM" src="https://user-images.githubusercontent.com/1151048/139132742-750cd937-f401-44e8-9a10-c21410073b5d.png">

<img width="379" alt="Screen Shot 2021-10-26 at 6 02 29 PM" src="https://user-images.githubusercontent.com/1151048/139132766-21b58bea-7f46-43a6-a0e9-f01632958eab.png">

Before for when you click "See the full error":
<img width="817" alt="Screen Shot 2021-10-26 at 5 58 47 PM" src="https://user-images.githubusercontent.com/1151048/139132980-de1942d6-7b03-4c08-b34a-1fc4a22d5207.png">

After for when you click "See the full error":
<img width="838" alt="Screen Shot 2021-10-27 at 1 48 16 PM" src="https://user-images.githubusercontent.com/1151048/139136581-af1e331e-ed77-4338-8fb0-c2457acd135f.png">

<img width="802" alt="Screen Shot 2021-10-27 at 1 26 31 PM" src="https://user-images.githubusercontent.com/1151048/139135083-9ca56940-30a8-4f83-9355-312307172834.png">

## timeline

Before:
<img width="441" alt="Screen Shot 2021-10-27 at 1 19 00 PM" src="https://user-images.githubusercontent.com/1151048/139136614-8360d6a6-d182-413e-b5d9-b18e3d70dc24.png">

<img width="827" alt="Screen Shot 2021-10-27 at 1 19 08 PM" src="https://user-images.githubusercontent.com/1151048/139136637-f9203ac2-0eea-4a77-9c53-ac2c20ab32e0.png">

After:
<img width="408" alt="Screen Shot 2021-10-27 at 1 49 45 PM" src="https://user-images.githubusercontent.com/1151048/139136758-7532a8ba-6d73-45e2-adbb-6756ee997289.png">

<img width="820" alt="Screen Shot 2021-10-27 at 1 49 50 PM" src="https://user-images.githubusercontent.com/1151048/139136774-26d4a8a2-caf0-4c6f-94d3-a6cd92b79f5f.png">

### Checklist

- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios

Co-authored-by: Frank Hassanabad <frank.hassanabad@elastic.co>
This commit is contained in:
Kibana Machine 2021-10-29 18:57:09 -04:00 committed by GitHub
parent 4e9b77906c
commit e4c93a87a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 866 additions and 87 deletions

View file

@ -19,7 +19,7 @@ describe('ImportDataModal', () => {
importComplete={jest.fn()}
checkBoxLabel="checkBoxLabel"
description="description"
errorMessage="errorMessage"
errorMessage={jest.fn()}
failedDetailed={jest.fn()}
importData={jest.fn()}
showCheckBox={true}

View file

@ -23,23 +23,16 @@ import React, { useCallback, useState } from 'react';
import {
ImportDataResponse,
ImportDataProps,
ImportRulesResponseError,
ImportResponseError,
} from '../../../detections/containers/detection_engine/rules';
import {
displayErrorToast,
displaySuccessToast,
useStateToaster,
errorToToaster,
} from '../toasters';
import { useAppToasts } from '../../hooks/use_app_toasts';
import * as i18n from './translations';
interface ImportDataModalProps {
checkBoxLabel: string;
closeModal: () => void;
description: string;
errorMessage: string;
failedDetailed: (id: string, statusCode: number, message: string) => string;
errorMessage: (totalCount: number) => string;
failedDetailed: (message: string) => string;
importComplete: () => void;
importData: (arg: ImportDataProps) => Promise<ImportDataResponse>;
showCheckBox: boolean;
@ -50,12 +43,6 @@ interface ImportDataModalProps {
title: string;
}
const isImportRulesResponseError = (
error: ImportRulesResponseError | ImportResponseError
): error is ImportRulesResponseError => {
return (error as ImportRulesResponseError).rule_id !== undefined;
};
/**
* Modal component for importing Rules from a json file
*/
@ -77,7 +64,7 @@ export const ImportDataModalComponent = ({
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [overwrite, setOverwrite] = useState(false);
const [, dispatchToaster] = useStateToaster();
const { addError, addSuccess } = useAppToasts();
const cleanupAndCloseModal = useCallback(() => {
setIsImporting(false);
@ -97,31 +84,39 @@ export const ImportDataModalComponent = ({
signal: abortCtrl.signal,
});
// TODO: Improve error toast details for better debugging failed imports
// e.g. When success == true && success_count === 0 that means no rules were overwritten, etc
if (importResponse.success) {
displaySuccessToast(successMessage(importResponse.success_count), dispatchToaster);
addSuccess(successMessage(importResponse.success_count));
}
if (importResponse.errors.length > 0) {
const formattedErrors = importResponse.errors.map((e) =>
failedDetailed(
isImportRulesResponseError(e) ? e.rule_id : e.id,
e.error.status_code,
e.error.message
)
const formattedErrors = importResponse.errors.map((e) => failedDetailed(e.error.message));
const error: Error & { raw_network_error?: object } = new Error(
formattedErrors.join('. ')
);
displayErrorToast(errorMessage, formattedErrors, dispatchToaster);
error.stack = undefined;
error.name = 'Network errors';
error.raw_network_error = importResponse;
addError(error, { title: errorMessage(importResponse.errors.length) });
}
importComplete();
cleanupAndCloseModal();
} catch (error) {
cleanupAndCloseModal();
errorToToaster({ title: errorMessage, error, dispatchToaster });
addError(error, { title: errorMessage(1) });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFiles, overwrite]);
}, [
selectedFiles,
overwrite,
addError,
addSuccess,
cleanupAndCloseModal,
errorMessage,
failedDetailed,
importComplete,
importData,
successMessage,
]);
const handleCloseModal = useCallback(() => {
setSelectedFiles(null);

View file

@ -579,19 +579,21 @@ export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) =>
}
);
export const IMPORT_FAILED = i18n.translate(
'xpack.securitySolution.detectionEngine.components.importRuleModal.importFailedTitle',
{
defaultMessage: 'Failed to import rules',
}
);
export const IMPORT_FAILED = (totalRules: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.components.importRuleModal.importFailedTitle',
{
values: { totalRules },
defaultMessage: 'Failed to import {totalRules} {totalRules, plural, =1 {rule} other {rules}}',
}
);
export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) =>
export const IMPORT_FAILED_DETAILED = (message: string) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.components.importRuleModal.importFailedDetailedTitle',
{
values: { ruleId, statusCode, message },
defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}',
values: { message },
defaultMessage: '{message}',
}
);

View file

@ -373,12 +373,15 @@ export const SUCCESSFULLY_IMPORTED_TIMELINES = (totalCount: number) =>
}
);
export const IMPORT_FAILED = i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle',
{
defaultMessage: 'Failed to import',
}
);
export const IMPORT_FAILED = (totalTimelines: number) =>
i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle',
{
values: { totalTimelines },
defaultMessage:
'Failed to import {totalTimelines} {totalTimelines, plural, =1 {rule} other {rules}}',
}
);
export const IMPORT_TIMELINE = i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.importTitle',
@ -387,11 +390,11 @@ export const IMPORT_TIMELINE = i18n.translate(
}
);
export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message: string) =>
export const IMPORT_FAILED_DETAILED = (message: string) =>
i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.importFailedDetailedTitle',
{
values: { id, statusCode, message },
defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}',
values: { message },
defaultMessage: '{message}',
}
);

View file

@ -11,6 +11,7 @@ import { coreMock } from 'src/core/server/mocks';
import { ActionsApiRequestHandlerContext } from '../../../../../../actions/server';
import { AlertingApiRequestHandlerContext } from '../../../../../../alerting/server';
import { rulesClientMock } from '../../../../../../alerting/server/mocks';
import { actionsClientMock } from '../../../../../../actions/server/mocks';
import { licensingMock } from '../../../../../../licensing/server/mocks';
import { listMock } from '../../../../../../lists/server/mocks';
import { ruleRegistryMocks } from '../../../../../../rule_registry/server/mocks';
@ -44,6 +45,7 @@ const createMockClients = () => {
exceptionListClient: listMock.getExceptionListClient(core.savedObjects.client),
},
rulesClient: rulesClientMock.create(),
actionsClient: actionsClientMock.create(),
ruleDataService: ruleRegistryMocks.createRuleDataService(),
config: createMockConfig(),
@ -65,7 +67,9 @@ const createRequestContextMock = (
return {
core: clients.core,
securitySolution: createSecuritySolutionRequestContextMock(clients),
actions: {} as unknown as jest.Mocked<ActionsApiRequestHandlerContext>,
actions: {
getActionsClient: jest.fn(() => clients.actionsClient),
} as unknown as jest.Mocked<ActionsApiRequestHandlerContext>,
alerting: {
getRulesClient: jest.fn(() => clients.rulesClient),
} as unknown as jest.Mocked<AlertingApiRequestHandlerContext>,

View file

@ -53,6 +53,7 @@ describe.each([
clients.rulesClient.update.mockResolvedValue(
getAlertMock(isRuleRegistryEnabled, getQueryRuleParams())
);
clients.actionsClient.getAll.mockResolvedValue([]);
context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue(
elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse())
);
@ -77,21 +78,6 @@ describe.each([
status_code: 500,
});
});
test('returns 404 if alertClient is not available on the route', async () => {
context.alerting.getRulesClient = jest.fn();
const response = await server.inject(request, context);
expect(response.status).toEqual(404);
expect(response.body).toEqual({ message: 'Not Found', status_code: 404 });
});
it('returns 404 if siem client is unavailable', async () => {
const { securitySolution, ...contextWithoutSecuritySolution } = context;
// @ts-expect-error
const response = await server.inject(request, contextWithoutSecuritySolution);
expect(response.status).toEqual(404);
expect(response.body).toEqual({ message: 'Not Found', status_code: 404 });
});
});
describe('unhappy paths', () => {

View file

@ -41,7 +41,7 @@ import {
import { patchRules } from '../../rules/patch_rules';
import { legacyMigrate } from '../../rules/utils';
import { getTupleDuplicateErrorsAndUniqueRules } from './utils';
import { getTupleDuplicateErrorsAndUniqueRules, getInvalidConnectors } from './utils';
import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson';
import { buildRouteValidation } from '../../../../utils/build_validation/route_validation';
import { HapiReadableStream } from '../../rules/types';
@ -78,14 +78,11 @@ export const importRulesRoute = (
const siemResponse = buildSiemResponse(response);
try {
const rulesClient = context.alerting?.getRulesClient();
const rulesClient = context.alerting.getRulesClient();
const actionsClient = context.actions.getActionsClient();
const esClient = context.core.elasticsearch.client;
const savedObjectsClient = context.core.savedObjects.client;
const siemClient = context.securitySolution?.getAppClient();
if (!siemClient || !rulesClient) {
return siemResponse.error({ statusCode: 404 });
}
const siemClient = context.securitySolution.getAppClient();
const mlAuthz = buildMlAuthz({
license: context.licensing.license,
@ -103,6 +100,7 @@ export const importRulesRoute = (
body: `Invalid file extension ${fileExtension}`,
});
}
const signalsIndex = siemClient.getSignalsIndex();
const indexExists = await getIndexExists(esClient.asCurrentUser, signalsIndex);
if (!isRuleRegistryEnabled && !indexExists) {
@ -118,14 +116,24 @@ export const importRulesRoute = (
request.body.file as HapiReadableStream,
...readStream,
]);
const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueRules(
parsedObjects,
request.query.overwrite
const [duplicateIdErrors, parsedObjectsWithoutDuplicateErrors] =
getTupleDuplicateErrorsAndUniqueRules(parsedObjects, request.query.overwrite);
const [nonExistentActionErrors, uniqueParsedObjects] = await getInvalidConnectors(
parsedObjectsWithoutDuplicateErrors,
actionsClient
);
const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects);
let importRuleResponse: ImportRuleResponse[] = [];
// If we had 100% errors and no successful rule could be imported we still have to output an error.
// otherwise we would output we are success importing 0 rules.
if (chunkParseObjects.length === 0) {
importRuleResponse = [...nonExistentActionErrors, ...duplicateIdErrors];
}
while (chunkParseObjects.length) {
const batchParseObjects = chunkParseObjects.shift() ?? [];
const newImportRuleResponse = await Promise.all(
@ -362,6 +370,7 @@ export const importRulesRoute = (
}, [])
);
importRuleResponse = [
...nonExistentActionErrors,
...duplicateIdErrors,
...importRuleResponse,
...newImportRuleResponse,

View file

@ -88,7 +88,6 @@ export const updateRulesBulkRoute = (
spaceId: context.securitySolution.getSpaceId(),
rulesClient,
ruleStatusClient,
savedObjectsClient,
defaultOutputIndex: siemClient.getSignalsIndex(),
ruleUpdate: payloadRule,
isRuleRegistryEnabled,

View file

@ -79,7 +79,6 @@ export const updateRulesRoute = (
isRuleRegistryEnabled,
rulesClient,
ruleStatusClient,
savedObjectsClient,
ruleUpdate: request.body,
spaceId: context.securitySolution.getSpaceId(),
});

View file

@ -18,6 +18,7 @@ import {
transformAlertsToRules,
getDuplicates,
getTupleDuplicateErrorsAndUniqueRules,
getInvalidConnectors,
} from './utils';
import { getAlertMock } from '../__mocks__/request_responses';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
@ -36,6 +37,8 @@ import {
getQueryRuleParams,
getThreatRuleParams,
} from '../../schemas/rule_schemas.mock';
import { requestContextMock } from '../__mocks__';
// eslint-disable-next-line no-restricted-imports
import { LegacyRulesActionsSavedObject } from '../../rule_actions/legacy_get_rule_actions_saved_object';
// eslint-disable-next-line no-restricted-imports
@ -47,6 +50,8 @@ describe.each([
['Legacy', false],
['RAC', true],
])('utils - %s', (_, isRuleRegistryEnabled) => {
const { clients } = requestContextMock.createTools();
describe('transformAlertToRule', () => {
test('should work with a full data set', () => {
const fullRule = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams());
@ -645,4 +650,468 @@ describe.each([
expect(errors.length).toEqual(0);
});
});
describe('getInvalidConnectors', () => {
beforeEach(() => {
clients.actionsClient.getAll.mockReset();
});
test('returns empty errors array and rule array with instance of Syntax Error when imported rule contains parse error', async () => {
const multipartPayload =
'{"name"::"Simple Rule Query","description":"Simple Rule Query","risk_score":1,"rule_id":"rule-1","severity":"high","type":"query","query":"user.name: root or user.name: admin"}\n';
const ndJsonStream = new Readable({
read() {
this.push(multipartPayload);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
const isInstanceOfError = output[0] instanceof Error;
expect(isInstanceOfError).toEqual(true);
expect(errors.length).toEqual(0);
});
test('creates error with a rule has an action that does not exist within the actions client', async () => {
const rule: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
],
};
const ndJsonStream = new Readable({
read() {
this.push(`${JSON.stringify(rule)}\n`);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
expect(output.length).toEqual(0);
expect(errors).toEqual<BulkError[]>([
{
error: {
message: '1 connector is missing. Connector id missing is: 123',
status_code: 404,
},
rule_id: 'rule-1',
},
]);
});
test('creates output with no errors if 1 rule with an action exists within the actions client', async () => {
const rule: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
],
};
const ndJsonStream = new Readable({
read() {
this.push(`${JSON.stringify(rule)}\n`);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([
{
id: '123',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
expect(errors.length).toEqual(0);
expect(output.length).toEqual(1);
expect(output[0]).toEqual<PromiseFromStreams[]>(expect.objectContaining(rule));
});
test('creates output with no errors if 1 rule with 2 actions exists within the actions client', async () => {
const rule: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
{
group: 'default',
id: '789',
action_type_id: '101112',
params: {},
},
],
};
const ndJsonStream = new Readable({
read() {
this.push(`${JSON.stringify(rule)}\n`);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([
{
id: '123',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
{
id: '789',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
expect(errors.length).toEqual(0);
expect(output.length).toEqual(1);
expect(output[0]).toEqual<PromiseFromStreams[]>(expect.objectContaining(rule));
});
test('creates output with no errors if 2 rules with 1 action each exists within the actions client', async () => {
const rule1: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
],
};
const rule2: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-2'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
],
};
const ndJsonStream = new Readable({
read() {
this.push(`${JSON.stringify(rule1)}\n`);
this.push(`${JSON.stringify(rule2)}\n`);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([
{
id: '123',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
{
id: '789',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
expect(errors.length).toEqual(0);
expect(output.length).toEqual(2);
expect(output[0]).toEqual<PromiseFromStreams[]>(expect.objectContaining(rule1));
expect(output[1]).toEqual<PromiseFromStreams[]>(expect.objectContaining(rule2));
});
test('creates output with 1 error if 2 rules with 1 action each exists within the actions client but 1 has a nonexistent action', async () => {
const rule1: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
],
};
const rule2: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-2'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
{
group: 'default',
id: '456', // <--- Non-existent that triggers the error.
action_type_id: '456',
params: {},
},
],
};
const ndJsonStream = new Readable({
read() {
this.push(`${JSON.stringify(rule1)}\n`);
this.push(`${JSON.stringify(rule2)}\n`);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([
{
id: '123',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
{
id: '789',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
expect(errors.length).toEqual(1);
expect(output.length).toEqual(1);
expect(output[0]).toEqual<PromiseFromStreams[]>(expect.objectContaining(rule1));
expect(errors).toEqual<BulkError[]>([
{
error: {
message: '1 connector is missing. Connector id missing is: 456',
status_code: 404,
},
rule_id: 'rule-2',
},
]);
});
test('creates output with error if 1 rule with 2 actions but 1 action does not exist within the actions client', async () => {
const rule: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
{
group: 'default',
id: '789',
action_type_id: '101112',
params: {},
},
{
group: 'default',
id: '101112', // <-- Does not exist
action_type_id: '101112',
params: {},
},
],
};
const ndJsonStream = new Readable({
read() {
this.push(`${JSON.stringify(rule)}\n`);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([
{
id: '123',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
{
id: '789',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
expect(errors.length).toEqual(1);
expect(output.length).toEqual(0);
expect(errors).toEqual<BulkError[]>([
{
error: {
message: '1 connector is missing. Connector id missing is: 101112',
status_code: 404,
},
rule_id: 'rule-1',
},
]);
});
test('creates output with 2 errors if 3 rules with actions but 1 action does not exist within the actions client', async () => {
const rule1: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
{
group: 'default',
id: '789',
action_type_id: '101112',
params: {},
},
{
group: 'default',
id: '101112', // <-- Does not exist
action_type_id: '101112',
params: {},
},
],
};
const rule2: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
{
group: 'default',
id: '789',
action_type_id: '101112',
params: {},
},
],
};
const rule3: ReturnType<typeof getCreateRulesSchemaMock> = {
...getCreateRulesSchemaMock('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
{
group: 'default',
id: '789',
action_type_id: '101112',
params: {},
},
{
group: 'default',
id: '101112', // <-- Does not exist
action_type_id: '101112',
params: {},
},
],
};
const ndJsonStream = new Readable({
read() {
this.push(`${JSON.stringify(rule1)}\n`);
this.push(`${JSON.stringify(rule2)}\n`);
this.push(`${JSON.stringify(rule3)}\n`);
this.push(null);
},
});
const rulesObjectsStream = createRulesStreamFromNdJson(1000);
const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([
ndJsonStream,
...rulesObjectsStream,
]);
clients.actionsClient.getAll.mockResolvedValue([
{
id: '123',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
{
id: '789',
referencedByCount: 1,
actionTypeId: 'default',
name: 'name',
isPreconfigured: false,
},
]);
const [errors, output] = await getInvalidConnectors(parsedObjects, clients.actionsClient);
expect(errors.length).toEqual(2);
expect(output.length).toEqual(1);
expect(output[0]).toEqual<PromiseFromStreams[]>(expect.objectContaining(rule2));
expect(errors).toEqual<BulkError[]>([
{
error: {
message: '1 connector is missing. Connector id missing is: 101112',
status_code: 404,
},
rule_id: 'rule-1',
},
{
error: {
message: '1 connector is missing. Connector id missing is: 101112',
status_code: 404,
},
rule_id: 'rule-1',
},
]);
});
});
});

View file

@ -13,6 +13,7 @@ import { RulesSchema } from '../../../../../common/detection_engine/schemas/resp
import { ImportRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/import_rules_schema';
import { CreateRulesBulkSchema } from '../../../../../common/detection_engine/schemas/request/create_rules_bulk_schema';
import { PartialAlert, FindResult } from '../../../../../../alerting/server';
import { ActionsClient } from '../../../../../../actions/server';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
import {
RuleAlertType,
@ -194,3 +195,57 @@ export const getTupleDuplicateErrorsAndUniqueRules = (
return [Array.from(errors.values()), Array.from(rulesAcc.values())];
};
/**
* Given a set of rules and an actions client this will return connectors that are invalid
* such as missing connectors and filter out the rules that have invalid connectors.
* @param rules The rules to check for invalid connectors
* @param actionsClient The actions client to get all the connectors.
* @returns An array of connector errors if it found any and then the promise stream of valid and invalid connectors.
*/
export const getInvalidConnectors = async (
rules: PromiseFromStreams[],
actionsClient: ActionsClient
): Promise<[BulkError[], PromiseFromStreams[]]> => {
const actionsFind = await actionsClient.getAll();
const actionIds = actionsFind.map((action) => action.id);
const { errors, rulesAcc } = rules.reduce(
(acc, parsedRule) => {
if (parsedRule instanceof Error) {
acc.rulesAcc.set(uuid.v4(), parsedRule);
} else {
const { rule_id: ruleId, actions } = parsedRule;
const missingActionIds = actions.flatMap((action) => {
if (actionIds.find((actionsId) => actionsId === action.id) == null) {
return [action.id];
} else {
return [];
}
});
if (missingActionIds.length === 0) {
acc.rulesAcc.set(ruleId, parsedRule);
} else {
const errorMessage =
missingActionIds.length > 1
? 'connectors are missing. Connector ids missing are:'
: 'connector is missing. Connector id missing is:';
acc.errors.set(
uuid.v4(),
createBulkErrorObject({
ruleId,
statusCode: 404,
message: `${missingActionIds.length} ${errorMessage} ${missingActionIds.join(', ')}`,
})
);
}
}
return acc;
}, // using map (preserves ordering)
{
errors: new Map<string, BulkError>(),
rulesAcc: new Map<string, PromiseFromStreams>(),
}
);
return [Array.from(errors.values()), Array.from(rulesAcc.values())];
};

View file

@ -275,7 +275,6 @@ export interface UpdateRulesOptions {
rulesClient: RulesClient;
defaultOutputIndex: string;
ruleUpdate: UpdateRulesSchema;
savedObjectsClient: SavedObjectsClientContract;
}
export interface PatchRulesOptions {

View file

@ -33,7 +33,6 @@ export const updateRules = async ({
ruleStatusClient,
defaultOutputIndex,
ruleUpdate,
savedObjectsClient,
}: UpdateRulesOptions): Promise<PartialAlert<RuleParams> | null> => {
const existingRule = await readRules({
isRuleRegistryEnabled,

View file

@ -20556,8 +20556,6 @@
"xpack.securitySolution.detectionEngine.components.allRules.refreshPromptConfirm": "続行",
"xpack.securitySolution.detectionEngine.components.allRules.refreshPromptTitle": "応答してください。",
"xpack.securitySolution.detectionEngine.components.importRuleModal.cancelTitle": "キャンセル",
"xpack.securitySolution.detectionEngine.components.importRuleModal.importFailedDetailedTitle": "ルールID{ruleId}\n ステータスコード:{statusCode}\n メッセージ:{message}",
"xpack.securitySolution.detectionEngine.components.importRuleModal.importFailedTitle": "ルールをインポートできませんでした",
"xpack.securitySolution.detectionEngine.components.importRuleModal.importRuleTitle": "ルールのインポート",
"xpack.securitySolution.detectionEngine.components.importRuleModal.initialPromptTextDescription": "有効なrules_export.ndjsonファイルを選択するか、ドラッグしてドロップします",
"xpack.securitySolution.detectionEngine.components.importRuleModal.overwriteDescription": "競合するルールIDで既存の検出を上書き",
@ -22820,8 +22818,6 @@
"xpack.securitySolution.timelines.allTimelines.errorFetchingTimelinesTitle": "すべてのタイムラインデータをクエリできませんでした",
"xpack.securitySolution.timelines.allTimelines.importTimelineTitle": "インポート",
"xpack.securitySolution.timelines.allTimelines.panelTitle": "すべてのタイムライン",
"xpack.securitySolution.timelines.components.importTimelineModal.importFailedDetailedTitle": "タイムライン ID{id}\n ステータスコード:{statusCode}\n メッセージ:{message}",
"xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle": "インポートできませんでした",
"xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle": "インポート",
"xpack.securitySolution.timelines.components.importTimelineModal.importTitle": "インポート…",
"xpack.securitySolution.timelines.components.importTimelineModal.initialPromptTextDescription": "有効な timelines_export.ndjson ファイルを選択するか、またはドラッグアンドドロップします",

View file

@ -20871,8 +20871,6 @@
"xpack.securitySolution.detectionEngine.components.allRules.refreshPromptConfirm": "继续",
"xpack.securitySolution.detectionEngine.components.allRules.refreshPromptTitle": "您还在吗?",
"xpack.securitySolution.detectionEngine.components.importRuleModal.cancelTitle": "取消",
"xpack.securitySolution.detectionEngine.components.importRuleModal.importFailedDetailedTitle": "规则 ID{ruleId}\n 状态代码:{statusCode}\n 消息:{message}",
"xpack.securitySolution.detectionEngine.components.importRuleModal.importFailedTitle": "无法导入规则",
"xpack.securitySolution.detectionEngine.components.importRuleModal.importRuleTitle": "导入规则",
"xpack.securitySolution.detectionEngine.components.importRuleModal.initialPromptTextDescription": "选择或拖放有效 rules_export.ndjson 文件",
"xpack.securitySolution.detectionEngine.components.importRuleModal.overwriteDescription": "覆盖具有冲突规则 ID 的现有检测规则",
@ -23193,8 +23191,6 @@
"xpack.securitySolution.timelines.allTimelines.errorFetchingTimelinesTitle": "无法查询所有时间线数据",
"xpack.securitySolution.timelines.allTimelines.importTimelineTitle": "导入",
"xpack.securitySolution.timelines.allTimelines.panelTitle": "所有时间线",
"xpack.securitySolution.timelines.components.importTimelineModal.importFailedDetailedTitle": "时间线 ID{id}\n 状态代码:{statusCode}\n 消息:{message}",
"xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle": "无法导入",
"xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle": "导入",
"xpack.securitySolution.timelines.components.importTimelineModal.importTitle": "导入……",
"xpack.securitySolution.timelines.components.importTimelineModal.initialPromptTextDescription": "搜索或拖放有效的 timelines_export.ndjson 文件",

View file

@ -17,6 +17,7 @@ import {
deleteSignalsIndex,
getSimpleRule,
getSimpleRuleOutput,
getWebHookAction,
removeServerGeneratedProperties,
} from '../../utils';
@ -109,6 +110,113 @@ export default ({ getService }: FtrProviderContext): void => {
getSimpleRuleOutput('rule-1'),
]);
});
it('should export multiple actions attached to 1 rule', async () => {
// 1st action
const { body: hookAction1 } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
// 2nd action
const { body: hookAction2 } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const action1 = {
group: 'default',
id: hookAction1.id,
action_type_id: hookAction1.actionTypeId,
params: {},
};
const action2 = {
group: 'default',
id: hookAction2.id,
action_type_id: hookAction2.actionTypeId,
params: {},
};
const rule1: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [action1, action2],
};
await createRule(supertest, rule1);
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_export`)
.set('kbn-xsrf', 'true')
.send()
.expect(200)
.parse(binaryToString);
const firstRuleParsed = JSON.parse(body.toString().split(/\n/)[0]);
const firstRule = removeServerGeneratedProperties(firstRuleParsed);
const outputRule1: ReturnType<typeof getSimpleRuleOutput> = {
...getSimpleRuleOutput('rule-1'),
actions: [action1, action2],
throttle: 'rule',
};
expect(firstRule).to.eql(outputRule1);
});
it('should export actions attached to 2 rules', async () => {
// create a new action
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const action = {
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
};
const rule1: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [action],
};
const rule2: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-2'),
actions: [action],
};
await createRule(supertest, rule1);
await createRule(supertest, rule2);
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_export`)
.set('kbn-xsrf', 'true')
.send()
.expect(200)
.parse(binaryToString);
const firstRuleParsed = JSON.parse(body.toString().split(/\n/)[0]);
const secondRuleParsed = JSON.parse(body.toString().split(/\n/)[1]);
const firstRule = removeServerGeneratedProperties(firstRuleParsed);
const secondRule = removeServerGeneratedProperties(secondRuleParsed);
const outputRule1: ReturnType<typeof getSimpleRuleOutput> = {
...getSimpleRuleOutput('rule-2'),
actions: [action],
throttle: 'rule',
};
const outputRule2: ReturnType<typeof getSimpleRuleOutput> = {
...getSimpleRuleOutput('rule-1'),
actions: [action],
throttle: 'rule',
};
expect(firstRule).to.eql(outputRule1);
expect(secondRule).to.eql(outputRule2);
});
});
});
};

View file

@ -16,6 +16,7 @@ import {
getSimpleRule,
getSimpleRuleAsNdjson,
getSimpleRuleOutput,
getWebHookAction,
removeServerGeneratedProperties,
ruleToNdjson,
} from '../../utils';
@ -315,6 +316,165 @@ export default ({ getService }: FtrProviderContext): void => {
getSimpleRuleOutput('rule-3'),
]);
});
it('should give single connector error back if we have a single connector error message', async () => {
const simpleRule: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [
{
group: 'default',
id: '123',
action_type_id: '456',
params: {},
},
],
};
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_import`)
.set('kbn-xsrf', 'true')
.attach('file', ruleToNdjson(simpleRule), 'rules.ndjson')
.expect(200);
expect(body).to.eql({
success: false,
success_count: 0,
errors: [
{
rule_id: 'rule-1',
error: {
status_code: 404,
message: '1 connector is missing. Connector id missing is: 123',
},
},
],
});
});
it('should be able to import a rule with an action connector that exists', async () => {
// create a new action
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const simpleRule: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [
{
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
},
],
};
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_import`)
.set('kbn-xsrf', 'true')
.attach('file', ruleToNdjson(simpleRule), 'rules.ndjson')
.expect(200);
expect(body).to.eql({ success: true, success_count: 1, errors: [] });
});
it('should be able to import 2 rules with action connectors that exist', async () => {
// create a new action
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const rule1: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [
{
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
},
],
};
const rule2: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-2'),
actions: [
{
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
},
],
};
const rule1String = JSON.stringify(rule1);
const rule2String = JSON.stringify(rule2);
const buffer = Buffer.from(`${rule1String}\n${rule2String}\n`);
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_import`)
.set('kbn-xsrf', 'true')
.attach('file', buffer, 'rules.ndjson')
.expect(200);
expect(body).to.eql({ success: true, success_count: 2, errors: [] });
});
it('should be able to import 1 rule with an action connector that exists and get 1 other error back for a second rule that does not have the connector', async () => {
// create a new action
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const rule1: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [
{
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
},
],
};
const rule2: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-2'),
actions: [
{
group: 'default',
id: '123', // <-- This does not exist
action_type_id: hookAction.actionTypeId,
params: {},
},
],
};
const rule1String = JSON.stringify(rule1);
const rule2String = JSON.stringify(rule2);
const buffer = Buffer.from(`${rule1String}\n${rule2String}\n`);
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_import`)
.set('kbn-xsrf', 'true')
.attach('file', buffer, 'rules.ndjson')
.expect(200);
expect(body).to.eql({
success: false,
success_count: 1,
errors: [
{
rule_id: 'rule-2',
error: {
status_code: 404,
message: '1 connector is missing. Connector id missing is: 123',
},
},
],
});
});
});
});
};