diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts index 8bddd4a1ef456..434a7fdb1984b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts @@ -125,6 +125,28 @@ export const createMockServerWithoutActionOrAlertClientDecoration = ( }; }; +export const createMockServerWithoutSavedObjectDecoration = ( + config: Record = defaultConfig +) => { + const serverWithoutSavedObjectClient = new Hapi.Server({ + port: 0, + }); + + serverWithoutSavedObjectClient.config = () => createMockKibanaConfig(config); + + const actionsClient = actionsClientMock.create(); + const alertsClient = alertsClientMock.create(); + + serverWithoutSavedObjectClient.decorate('request', 'getAlertsClient', () => alertsClient); + serverWithoutSavedObjectClient.decorate('request', 'getActionsClient', () => actionsClient); + serverWithoutSavedObjectClient.plugins.spaces = { getSpaceId: () => 'default' }; + return { + serverWithoutSavedObjectClient: serverWithoutSavedObjectClient as ServerFacade & Hapi.Server, + alertsClient, + actionsClient, + }; +}; + export const getMockIndexName = () => jest.fn().mockImplementation(() => ({ callWithRequest: jest.fn().mockImplementationOnce(() => 'index-name'), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 85570b1b59656..a8f0a79308618 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -19,6 +19,7 @@ import { } from '../../../../../common/constants'; import { RuleAlertType, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { RuleAlertParamsRest, PrepackagedRules } from '../../types'; +import { TEST_BOUNDARY } from './utils'; export const mockPrepackagedRule = (): PrepackagedRules => ({ rule_id: 'rule-1', @@ -223,6 +224,24 @@ export const getFindResultWithMultiHits = ({ }; }; +export const getImportRulesRequest = (payload?: Buffer): ServerInjectOptions => ({ + method: 'POST', + url: `${DETECTION_ENGINE_RULES_URL}/_import`, + headers: { + 'Content-Type': `multipart/form-data; boundary=${TEST_BOUNDARY}`, + }, + payload, +}); + +export const getImportRulesRequestOverwriteTrue = (payload?: Buffer): ServerInjectOptions => ({ + method: 'POST', + url: `${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`, + headers: { + 'Content-Type': `multipart/form-data; boundary=${TEST_BOUNDARY}`, + }, + payload, +}); + export const getDeleteRequest = (): ServerInjectOptions => ({ method: 'DELETE', url: `${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts new file mode 100644 index 0000000000000..f8c8e1f231ffa --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OutputRuleAlertRest } from '../../types'; + +export const TEST_BOUNDARY = 'test_multipart_boundary'; + +// Not parsable due to extra colon following `name` property - name:: +export const UNPARSABLE_LINE = + '{"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"}'; + +/** + * This is a typical simple rule for testing that is easy for most basic testing + * @param ruleId + */ +export const getSimpleRule = (ruleId = 'rule-1'): Partial => ({ + name: 'Simple Rule Query', + description: 'Simple Rule Query', + risk_score: 1, + rule_id: ruleId, + severity: 'high', + type: 'query', + query: 'user.name: root or user.name: admin', +}); + +/** + * Given an array of rule_id strings this will return a ndjson buffer which is useful + * for testing uploads. + * @param ruleIds Array of strings of rule_ids + * @param isNdjson Boolean to determine file extension + */ +export const getSimpleRuleAsMultipartContent = (ruleIds: string[], isNdjson = true): Buffer => { + const arrayOfRules = ruleIds.map(ruleId => { + const simpleRule = getSimpleRule(ruleId); + return JSON.stringify(simpleRule); + }); + const stringOfRules = arrayOfRules.join('\r\n'); + + const resultingPayload = + `--${TEST_BOUNDARY}\r\n` + + `Content-Disposition: form-data; name="file"; filename="rules.${ + isNdjson ? 'ndjson' : 'json' + }\r\n` + + 'Content-Type: application/octet-stream\r\n' + + '\r\n' + + `${stringOfRules}\r\n` + + `--${TEST_BOUNDARY}--\r\n`; + + return Buffer.from(resultingPayload); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts new file mode 100644 index 0000000000000..ad017a3596a23 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -0,0 +1,415 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getSimpleRuleAsMultipartContent, + TEST_BOUNDARY, + UNPARSABLE_LINE, + getSimpleRule, +} from '../__mocks__/utils'; +import { + createMockServer, + createMockServerWithoutAlertClientDecoration, + createMockServerWithoutSavedObjectDecoration, + getMockNonEmptyIndex, + getMockEmptyIndex, + createMockServerWithoutActionClientDecoration, +} from '../__mocks__/_mock_server'; +import { ImportSuccessError } from '../utils'; +import { + getImportRulesRequest, + getImportRulesRequestOverwriteTrue, + getFindResult, + getResult, + createActionResult, + getFindResultWithSingleHit, + getFindResultStatus, +} from '../__mocks__/request_responses'; + +import { importRulesRoute } from './import_rules_route'; + +describe('import_rules_route', () => { + let { + server, + alertsClient, + actionsClient, + elasticsearch, + savedObjectsClient, + } = createMockServer(); + + beforeEach(() => { + jest.resetAllMocks(); + ({ + server, + alertsClient, + actionsClient, + elasticsearch, + savedObjectsClient, + } = createMockServer()); + elasticsearch.getCluster = getMockNonEmptyIndex(); + importRulesRoute(server); + }); + + describe('status codes with savedObjectsClient and alertClient', () => { + test('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + importRulesRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject( + getImportRulesRequest(getSimpleRuleAsMultipartContent(['rule-1'])) + ); + expect(statusCode).toEqual(404); + }); + + test('returns 404 if actionClient is not available on the route', async () => { + const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); + importRulesRoute(serverWithoutActionClient); + const { statusCode } = await serverWithoutActionClient.inject( + getImportRulesRequest(getSimpleRuleAsMultipartContent(['rule-1'])) + ); + expect(statusCode).toBe(404); + }); + + test('returns 404 if savedObjectsClient is not available on the route', async () => { + const { serverWithoutSavedObjectClient } = createMockServerWithoutSavedObjectDecoration(); + importRulesRoute(serverWithoutSavedObjectClient); + const { statusCode } = await serverWithoutSavedObjectClient.inject( + getImportRulesRequest(getSimpleRuleAsMultipartContent(['rule-1'])) + ); + + expect(statusCode).toEqual(404); + }); + + test('returns reported error if index does not exist', async () => { + elasticsearch.getCluster = getMockEmptyIndex(); + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.create.mockResolvedValue(createActionResult()); + alertsClient.create.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [ + { + error: { + message: + 'To create a rule, the index must exist first. Index .siem-signals-default does not exist', + status_code: 409, + }, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 0, + }); + expect(statusCode).toEqual(200); + }); + }); + + describe('payload', () => { + test('returns 400 if file extension type is not .ndjson', async () => { + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1'], false); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + message: 'Invalid file extension .json', + status_code: 400, + }); + expect(statusCode).toEqual(400); + }); + }); + + describe('single rule import', () => { + test('returns 200 if rule imported successfully', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.create.mockResolvedValue(createActionResult()); + alertsClient.create.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + expect(statusCode).toEqual(200); + }); + + test('returns reported conflict if error parsing rule', async () => { + const multipartPayload = + `--${TEST_BOUNDARY}\r\n` + + `Content-Disposition: form-data; name="file"; filename="rules.ndjson"\r\n` + + 'Content-Type: application/octet-stream\r\n' + + '\r\n' + + `${UNPARSABLE_LINE}\r\n` + + `--${TEST_BOUNDARY}--\r\n`; + + alertsClient.find.mockResolvedValue(getFindResult()); + + const requestPayload = Buffer.from(multipartPayload); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [ + { + error: { + message: 'Unexpected token : in JSON at position 8', + status_code: 400, + }, + rule_id: '(unknown)', + }, + ], + success: false, + success_count: 0, + }); + expect(statusCode).toEqual(200); + }); + + describe('rule with existing rule_id', () => { + test('returns with reported conflict if `overwrite` is set to `false`', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + expect(statusCode).toEqual(200); + + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + + const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject( + getImportRulesRequest(requestPayload) + ); + const parsedRequest2: ImportSuccessError = JSON.parse(payloadRequest2); + + expect(parsedRequest2).toEqual({ + errors: [ + { + error: { + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 0, + }); + expect(statusCodeRequest2).toEqual(200); + }); + + test('returns with NO reported conflict if `overwrite` is set to `true`', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + expect(statusCode).toEqual(200); + + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + + const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject( + getImportRulesRequestOverwriteTrue(requestPayload) + ); + const parsedRequest2: ImportSuccessError = JSON.parse(payloadRequest2); + + expect(parsedRequest2).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + expect(statusCodeRequest2).toEqual(200); + }); + }); + }); + + describe('multi rule import', () => { + test('returns 200 if all rules imported successfully', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1', 'rule-2']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [], + success: true, + success_count: 2, + }); + expect(statusCode).toEqual(200); + }); + + test('returns 200 with reported conflict if error parsing rule', async () => { + const multipartPayload = + `--${TEST_BOUNDARY}\r\n` + + `Content-Disposition: form-data; name="file"; filename="rules.ndjson"\r\n` + + 'Content-Type: application/octet-stream\r\n' + + '\r\n' + + `${UNPARSABLE_LINE}\r\n` + + `${JSON.stringify(getSimpleRule('rule-2'))}\r\n` + + `--${TEST_BOUNDARY}--\r\n`; + + alertsClient.find.mockResolvedValue(getFindResult()); + + const requestPayload = Buffer.from(multipartPayload); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [ + { + error: { + message: 'Unexpected token : in JSON at position 8', + status_code: 400, + }, + rule_id: '(unknown)', + }, + ], + success: false, + success_count: 1, + }); + expect(statusCode).toEqual(200); + }); + + describe('rules with matching rule_id', () => { + test('returns with reported conflict if `overwrite` is set to `false`', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1', 'rule-1']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [ + { + error: { + message: 'More than one rule with rule-id: "rule-1" found', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 1, + }); + expect(statusCode).toEqual(200); + }); + + test('returns with NO reported conflict if `overwrite` is set to `true`', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1', 'rule-1']); + const { statusCode, payload } = await server.inject( + getImportRulesRequestOverwriteTrue(requestPayload) + ); + const parsed: ImportSuccessError = JSON.parse(payload); + + expect(parsed).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + expect(statusCode).toEqual(200); + }); + }); + + describe('rules with existing rule_id', () => { + test('returns with reported conflict if `overwrite` is set to `false`', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsedResult: ImportSuccessError = JSON.parse(payload); + + expect(parsedResult).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + expect(statusCode).toEqual(200); + + alertsClient.find.mockResolvedValueOnce(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + + const requestPayload2 = getSimpleRuleAsMultipartContent(['rule-1', 'rule-2', 'rule-3']); + const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject( + getImportRulesRequest(requestPayload2) + ); + const parsed: ImportSuccessError = JSON.parse(payloadRequest2); + + expect(parsed).toEqual({ + errors: [ + { + error: { + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 2, + }); + expect(statusCodeRequest2).toEqual(200); + }); + + test('returns 200 with NO reported conflict if `overwrite` is set to `true`', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + + const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']); + const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload)); + const parsedResult: ImportSuccessError = JSON.parse(payload); + + expect(parsedResult).toEqual({ + errors: [], + success: true, + success_count: 1, + }); + expect(statusCode).toEqual(200); + + alertsClient.find.mockResolvedValueOnce(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + + const requestPayload2 = getSimpleRuleAsMultipartContent(['rule-1', 'rule-2', 'rule-3']); + const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject( + getImportRulesRequestOverwriteTrue(requestPayload2) + ); + const parsed: ImportSuccessError = JSON.parse(payloadRequest2); + + expect(parsed).toEqual({ + errors: [], + success: true, + success_count: 3, + }); + expect(statusCodeRequest2).toEqual(200); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 5843e290bb899..bca2a8e833d4a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -7,7 +7,6 @@ import Hapi from 'hapi'; import { chunk, isEmpty, isFunction } from 'lodash/fp'; import { extname } from 'path'; -import uuid from 'uuid'; import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { createRules } from '../../rules/create_rules'; @@ -25,6 +24,7 @@ import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_fro import { ImportRuleAlertRest } from '../../types'; import { patchRules } from '../../rules/patch_rules'; import { importRulesQuerySchema, importRulesPayloadSchema } from '../schemas/import_rules_schema'; +import { getTupleDuplicateErrorsAndUniqueRules } from './utils'; type PromiseFromStreams = ImportRuleAlertRest | Error; @@ -74,25 +74,9 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = const objectLimit = server.config().get('savedObjects.maxImportExportSize'); const readStream = createRulesStreamFromNdJson(request.payload.file, objectLimit); const parsedObjects = await createPromiseFromStreams([readStream]); - const uniqueParsedObjects = Array.from( - parsedObjects - .reduce( - (acc, parsedRule) => { - if (parsedRule instanceof Error) { - acc.set(uuid.v4(), parsedRule); - } else { - const { rule_id: ruleId } = parsedRule; - if (ruleId != null) { - acc.set(ruleId, parsedRule); - } else { - acc.set(uuid.v4(), parsedRule); - } - } - return acc; - }, // using map (preserves ordering) - new Map() - ) - .values() + const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueRules( + parsedObjects, + request.query.overwrite ); const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects); @@ -247,7 +231,11 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = return [...accum, importsWorkerPromise]; }, []) ); - importRuleResponse = [...importRuleResponse, ...newImportRuleResponse]; + importRuleResponse = [ + ...duplicateIdErrors, + ...importRuleResponse, + ...newImportRuleResponse, + ]; } const errorsResp = importRuleResponse.filter(resp => !isEmpty(resp.error)); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index fb3262c476b40..2b0da8251b387 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { Readable } from 'stream'; import { transformAlertToRule, getIdError, @@ -16,12 +16,18 @@ import { transformAlertsToRules, transformOrImportError, getDuplicates, + getTupleDuplicateErrorsAndUniqueRules, } from './utils'; import { getResult } from '../__mocks__/request_responses'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; -import { OutputRuleAlertRest } from '../../types'; +import { OutputRuleAlertRest, ImportRuleAlertRest } from '../../types'; import { BulkError, ImportSuccessError } from '../utils'; import { sampleRule } from '../../signals/__mocks__/es_results'; +import { getSimpleRule } from '../__mocks__/utils'; +import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; +import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams'; + +type PromiseFromStreams = ImportRuleAlertRest | Error; describe('utils', () => { describe('transformAlertToRule', () => { @@ -1224,4 +1230,95 @@ describe('utils', () => { expect(output).toEqual(expected); }); }); + + describe('getTupleDuplicateErrorsAndUniqueRules', () => { + test('returns tuple of empty duplicate 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(ndJsonStream, 1000); + const parsedObjects = await createPromiseFromStreams([ + rulesObjectsStream, + ]); + const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, false); + const isInstanceOfError = output[0] instanceof Error; + + expect(isInstanceOfError).toEqual(true); + expect(errors.length).toEqual(0); + }); + + test('returns tuple of duplicate conflict error and single rule when rules with matching rule-ids passed in and `overwrite` is false', async () => { + const rule = getSimpleRule('rule-1'); + const rule2 = getSimpleRule('rule-1'); + const ndJsonStream = new Readable({ + read() { + this.push(`${JSON.stringify(rule)}\n`); + this.push(`${JSON.stringify(rule2)}\n`); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const parsedObjects = await createPromiseFromStreams([ + rulesObjectsStream, + ]); + const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, false); + + expect(output.length).toEqual(1); + expect(errors).toEqual([ + { + error: { + message: 'More than one rule with rule-id: "rule-1" found', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); + + test('returns tuple of empty duplicate errors array and single rule when rules with matching rule-ids passed in and `overwrite` is true', async () => { + const rule = getSimpleRule('rule-1'); + const rule2 = getSimpleRule('rule-1'); + const ndJsonStream = new Readable({ + read() { + this.push(`${JSON.stringify(rule)}\n`); + this.push(`${JSON.stringify(rule2)}\n`); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const parsedObjects = await createPromiseFromStreams([ + rulesObjectsStream, + ]); + const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, true); + + expect(output.length).toEqual(1); + expect(errors.length).toEqual(0); + }); + + test('returns tuple of empty duplicate errors array and single rule when rules without a rule-id is passed in', async () => { + const simpleRule = getSimpleRule(); + delete simpleRule.rule_id; + const multipartPayload = `${JSON.stringify(simpleRule)}\n`; + const ndJsonStream = new Readable({ + read() { + this.push(multipartPayload); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const parsedObjects = await createPromiseFromStreams([ + rulesObjectsStream, + ]); + const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, false); + const isInstanceOfError = output[0] instanceof Error; + + expect(isInstanceOfError).toEqual(true); + expect(errors.length).toEqual(0); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index df9e3021e400f..21fc5a12db536 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -7,6 +7,7 @@ import { pickBy } from 'lodash/fp'; import { Dictionary } from 'lodash'; import { SavedObject } from 'kibana/server'; +import uuid from 'uuid'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { RuleAlertType, @@ -17,7 +18,7 @@ import { isRuleStatusFindTypes, isRuleStatusSavedObjectType, } from '../../rules/types'; -import { OutputRuleAlertRest } from '../../types'; +import { OutputRuleAlertRest, ImportRuleAlertRest } from '../../types'; import { createBulkErrorObject, BulkError, @@ -27,6 +28,8 @@ import { OutputError, } from '../utils'; +type PromiseFromStreams = ImportRuleAlertRest | Error; + export const getIdError = ({ id, ruleId, @@ -224,3 +227,41 @@ export const getDuplicates = (lodashDict: Dictionary): string[] => { } return []; }; + +export const getTupleDuplicateErrorsAndUniqueRules = ( + rules: PromiseFromStreams[], + isOverwrite: boolean +): [BulkError[], PromiseFromStreams[]] => { + const { errors, rulesAcc } = rules.reduce( + (acc, parsedRule) => { + if (parsedRule instanceof Error) { + acc.rulesAcc.set(uuid.v4(), parsedRule); + } else { + const { rule_id: ruleId } = parsedRule; + if (ruleId != null) { + if (acc.rulesAcc.has(ruleId) && !isOverwrite) { + acc.errors.set( + uuid.v4(), + createBulkErrorObject({ + ruleId, + statusCode: 400, + message: `More than one rule with rule-id: "${ruleId}" found`, + }) + ); + } + acc.rulesAcc.set(ruleId, parsedRule); + } else { + acc.rulesAcc.set(uuid.v4(), parsedRule); + } + } + + return acc; + }, // using map (preserves ordering) + { + errors: new Map(), + rulesAcc: new Map(), + } + ); + + return [Array.from(errors.values()), Array.from(rulesAcc.values())]; +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index e8fd1e4298c22..79a1e667e5458 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -115,8 +115,16 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); expect(body).to.eql({ - errors: [], // TODO: This should have a conflict within it as an error rather than an empty array - success: true, + errors: [ + { + error: { + message: 'More than one rule with rule-id: "rule-1" found', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ], + success: false, success_count: 1, }); });