From 7c0e3d3e7b1541170306e527d0fbce19f4d5aa17 Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Mon, 19 Feb 2024 14:35:32 +0100 Subject: [PATCH] [Security Solution] Fix not complete existing rule overwrite when importing rules (#176166) **Fixes: https://github.com/elastic/kibana/issues/93342** **Fixes: https://github.com/elastic/kibana/issues/118166** ## Summary This PR fixes not complete existing rule overwrite when importing rules. ## Details When importing a rule and attempting to overwrite an existing rule, if the new rule does not define a field that the existing rule did define then the newly imported rule will include the field from the existing rule. This can cause issues if we want to overwrite a rule with a rule of a different type, e.g. going from saved_query to query we would provide a new rule that doesn't have a saved_id but since saved_id was defined on the old saved_query rule it will be included in the new query rule. The fix simply swaps out the `patchRules()` for `updateRules()`. Patching rules preserves previous field values if an incoming update doesn't have such fields while updating doesn't do that. The diff in `import_rules_utils.test.ts` looks bigger due to removing unnecessary `else` clause. ### 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 - [x] Ran successfully in Flaky test runner ([basic/essentials license FTR tests](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5166) and [trial/complete tier license FTR tests](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5167)) (cherry picked from commit 53aaab47322fd15ad232d71a1749fd2df8a5dde4) # Conflicts: # x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts # x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts # x-pack/test/detection_engine_api_integration/basic/tests/import_rules_with_overwrite.ts # x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts # x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_connectors.ts # x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_export_rules.ts # x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts # x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules_with_overwrite.ts # x-pack/test/detection_engine_api_integration/utils/get_rules_as_ndjson.ts # x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/index.ts # x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts # x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_rules_ess.ts # x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/index.ts # x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts --- .../logic/crud/update_rules.ts | 3 + .../logic/import/import_rules_utils.test.ts | 67 +- .../logic/import/import_rules_utils.ts | 156 +- .../basic/tests/export_rules.ts | 59 +- .../basic/tests/import_rules.ts | 447 ++--- .../tests/import_rules_with_overwrite.ts | 197 ++ .../group1/export_rules.ts | 172 +- .../group10/import_connectors.ts | 1 - .../group10/import_export_rules.ts | 120 +- .../group10/import_rules.ts | 1600 +++++++---------- .../group10/import_rules_with_overwrite.ts | 197 ++ .../utils/create_connector.ts | 29 + .../utils/fetch_rule.ts | 31 + .../utils/get_rules_as_ndjson.ts | 18 - .../get_threshold_rule_for_alert_testing.ts | 58 + .../utils/get_web_hook_connector_params.ts | 23 + .../utils/index.ts | 5 +- .../export_rules.ts | 432 +++++ .../import_rules_ess.ts | 375 ++++ .../trial_license_complete_tier/index.ts | 15 + .../utils/connectors/create_connector.ts | 8 +- .../get_web_hook_connector_params.ts | 23 + .../rules/check_investigation_field_in_so.ts | 38 + .../detections_response/utils/rules/index.ts | 4 +- 24 files changed, 2648 insertions(+), 1430 deletions(-) create mode 100644 x-pack/test/detection_engine_api_integration/basic/tests/import_rules_with_overwrite.ts create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules_with_overwrite.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/create_connector.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/fetch_rule.ts delete mode 100644 x-pack/test/detection_engine_api_integration/utils/get_rules_as_ndjson.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/get_threshold_rule_for_alert_testing.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/get_web_hook_connector_params.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_rules_ess.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/index.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_web_hook_connector_params.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/check_investigation_field_in_so.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts index cfbf6a639bb5e..4ffea7d55c9a9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts @@ -19,12 +19,14 @@ export interface UpdateRulesOptions { rulesClient: RulesClient; existingRule: RuleAlertType | null | undefined; ruleUpdate: RuleUpdateProps; + allowMissingConnectorSecrets?: boolean; } export const updateRules = async ({ rulesClient, existingRule, ruleUpdate, + allowMissingConnectorSecrets, }: UpdateRulesOptions): Promise | null> => { if (existingRule == null) { return null; @@ -81,6 +83,7 @@ export const updateRules = async ({ const update = await rulesClient.update({ id: existingRule.id, data: newInternalRule, + allowMissingConnectorSecrets, }); if (existingRule.enabled && enabled === false) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.test.ts index 5b097bacf2d9c..7842febda6821 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.test.ts @@ -13,14 +13,15 @@ import { getRuleMock, getEmptyFindResult, getFindResultWithSingleHit, + getFindResultWithMultiHits, } from '../../../routes/__mocks__/request_responses'; import { createRules } from '../crud/create_rules'; -import { patchRules } from '../crud/patch_rules'; +import { updateRules } from '../crud/update_rules'; import { importRules } from './import_rules_utils'; jest.mock('../crud/create_rules'); -jest.mock('../crud/patch_rules'); +jest.mock('../crud/update_rules'); describe('importRules', () => { const mlAuthz = { @@ -84,7 +85,7 @@ describe('importRules', () => { expect(result).toEqual([{ rule_id: 'rule-1', status_code: 200 }]); expect(createRules).toHaveBeenCalled(); - expect(patchRules).not.toHaveBeenCalled(); + expect(updateRules).not.toHaveBeenCalled(); }); it('reports error if "overwriteRules" is "false" and matching rule found', async () => { @@ -106,10 +107,10 @@ describe('importRules', () => { }, ]); expect(createRules).not.toHaveBeenCalled(); - expect(patchRules).not.toHaveBeenCalled(); + expect(updateRules).not.toHaveBeenCalled(); }); - it('patches rule if "overwriteRules" is "true" and matching rule found', async () => { + it('updates rule if "overwriteRules" is "true" and matching rule found', async () => { clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit()); const result = await importRules({ @@ -129,7 +130,53 @@ describe('importRules', () => { expect(result).toEqual([{ rule_id: 'rule-1', status_code: 200 }]); expect(createRules).not.toHaveBeenCalled(); - expect(patchRules).toHaveBeenCalled(); + expect(updateRules).toHaveBeenCalled(); + }); + + /** + * Existing rule may have nullable fields set to a value (e.g. `timestamp_override` is set to `some.value`) but + * a rule to import doesn't have these fields set (e.g. `timestamp_override` is NOT present at all in the ndjson file). + * We expect the updated rule won't have such fields preserved (e.g. `timestamp_override` will be removed). + * + * Unit test is only able to check `updateRules()` receives a proper update object. + */ + it('ensures overwritten rule DOES NOT preserve fields missed in the imported rule when "overwriteRules" is "true" and matching rule found', async () => { + const existingRule = getRuleMock( + getQueryRuleParams({ + timestampOverride: 'some.value', + }) + ); + + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ data: [existingRule] }) + ); + + const result = await importRules({ + ruleChunks: [ + [ + { + ...getImportRulesSchemaMock(), + rule_id: 'rule-1', + }, + ], + ], + rulesResponseAcc: [], + mlAuthz, + overwriteRules: true, + rulesClient: context.alerting.getRulesClient(), + existingLists: {}, + }); + + expect(result).toEqual([{ rule_id: 'rule-1', status_code: 200 }]); + expect(createRules).not.toHaveBeenCalled(); + expect(updateRules).toHaveBeenCalledWith( + expect.objectContaining({ + ruleUpdate: expect.not.objectContaining({ + timestamp_override: expect.anything(), + timestampOverride: expect.anything(), + }), + }) + ); }); it('reports error if rulesClient throws', async () => { @@ -154,7 +201,7 @@ describe('importRules', () => { }, ]); expect(createRules).not.toHaveBeenCalled(); - expect(patchRules).not.toHaveBeenCalled(); + expect(updateRules).not.toHaveBeenCalled(); }); it('reports error if "createRules" throws', async () => { @@ -180,8 +227,8 @@ describe('importRules', () => { ]); }); - it('reports error if "patchRules" throws', async () => { - (patchRules as jest.Mock).mockRejectedValue(new Error('error patching rule')); + it('reports error if "updateRules" throws', async () => { + (updateRules as jest.Mock).mockRejectedValue(new Error('import rule error')); clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit()); const result = await importRules({ @@ -196,7 +243,7 @@ describe('importRules', () => { expect(result).toEqual([ { error: { - message: 'error patching rule', + message: 'import rule error', status_code: 400, }, rule_id: 'rule-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts index 81940848b7dc4..179c4069596e7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts @@ -19,7 +19,7 @@ import type { ImportRuleResponse } from '../../../routes/utils'; import { createBulkErrorObject } from '../../../routes/utils'; import { createRules } from '../crud/create_rules'; import { readRules } from '../crud/read_rules'; -import { patchRules } from '../crud/patch_rules'; +import { updateRules } from '../crud/update_rules'; import type { MlAuthz } from '../../../../machine_learning/authz'; import { throwAuthzError } from '../../../../machine_learning/validation'; import { checkRuleExceptionReferences } from './check_rule_exception_references'; @@ -68,96 +68,94 @@ export const importRules = async ({ // otherwise we would output we are success importing 0 rules. if (ruleChunks.length === 0) { return importRuleResponse; - } else { - while (ruleChunks.length) { - const batchParseObjects = ruleChunks.shift() ?? []; - const newImportRuleResponse = await Promise.all( - batchParseObjects.reduce>>((accum, parsedRule) => { - const importsWorkerPromise = new Promise(async (resolve, reject) => { + } + + while (ruleChunks.length) { + const batchParseObjects = ruleChunks.shift() ?? []; + const newImportRuleResponse = await Promise.all( + batchParseObjects.reduce>>((accum, parsedRule) => { + const importsWorkerPromise = new Promise(async (resolve, reject) => { + try { + if (parsedRule instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedRule.message, + }) + ); + return null; + } + try { - if (parsedRule instanceof Error) { - // If the JSON object had a validation or parse error then we return - // early with the error and an (unknown) for the ruleId - resolve( - createBulkErrorObject({ - statusCode: 400, - message: parsedRule.message, - }) - ); - return null; - } + const [exceptionErrors, exceptions] = checkRuleExceptionReferences({ + rule: parsedRule, + existingLists, + }); - try { - const [exceptionErrors, exceptions] = checkRuleExceptionReferences({ - rule: parsedRule, - existingLists, - }); + importRuleResponse = [...importRuleResponse, ...exceptionErrors]; - importRuleResponse = [...importRuleResponse, ...exceptionErrors]; + throwAuthzError(await mlAuthz.validateRuleType(parsedRule.type)); + const rule = await readRules({ + rulesClient, + ruleId: parsedRule.rule_id, + id: undefined, + }); - throwAuthzError(await mlAuthz.validateRuleType(parsedRule.type)); - const rule = await readRules({ + if (rule == null) { + await createRules({ rulesClient, - ruleId: parsedRule.rule_id, - id: undefined, + params: { + ...parsedRule, + exceptions_list: [...exceptions], + }, + allowMissingConnectorSecrets, }); - - if (rule == null) { - await createRules({ - rulesClient, - params: { - ...parsedRule, - exceptions_list: [...exceptions], - }, - allowMissingConnectorSecrets, - }); - resolve({ - rule_id: parsedRule.rule_id, - status_code: 200, - }); - } else if (rule != null && overwriteRules) { - await patchRules({ - rulesClient, - existingRule: rule, - nextParams: { - ...parsedRule, - exceptions_list: [...exceptions], - }, - allowMissingConnectorSecrets, - shouldIncrementRevision: false, - }); - resolve({ - rule_id: parsedRule.rule_id, - status_code: 200, - }); - } else if (rule != null) { - resolve( - createBulkErrorObject({ - ruleId: parsedRule.rule_id, - statusCode: 409, - message: `rule_id: "${parsedRule.rule_id}" already exists`, - }) - ); - } - } catch (err) { + resolve({ + rule_id: parsedRule.rule_id, + status_code: 200, + }); + } else if (rule != null && overwriteRules) { + await updateRules({ + rulesClient, + existingRule: rule, + ruleUpdate: { + ...parsedRule, + exceptions_list: [...exceptions], + }, + }); + resolve({ + rule_id: parsedRule.rule_id, + status_code: 200, + }); + } else if (rule != null) { resolve( createBulkErrorObject({ ruleId: parsedRule.rule_id, - statusCode: err.statusCode ?? 400, - message: err.message, + statusCode: 409, + message: `rule_id: "${parsedRule.rule_id}" already exists`, }) ); } - } catch (error) { - reject(error); + } catch (err) { + resolve( + createBulkErrorObject({ + ruleId: parsedRule.rule_id, + statusCode: err.statusCode ?? 400, + message: err.message, + }) + ); } - }); - return [...accum, importsWorkerPromise]; - }, []) - ); - importRuleResponse = [...importRuleResponse, ...newImportRuleResponse]; - } - - return importRuleResponse; + } catch (error) { + reject(error); + } + }); + return [...accum, importsWorkerPromise]; + }, []) + ); + importRuleResponse = [...importRuleResponse, ...newImportRuleResponse]; } + + return importRuleResponse; }; diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts index 883c9adcc7ad0..cbb8466fbda42 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -15,9 +15,7 @@ import { createSignalsIndex, deleteAllRules, deleteAllAlerts, - getSimpleRule, - getSimpleRuleOutput, - removeServerGeneratedProperties, + getCustomQueryRuleParams, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -38,7 +36,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should set the response content types to be expected', async () => { - await createRule(supertest, log, getSimpleRule()); + await createRule(supertest, log, getCustomQueryRuleParams()); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -51,7 +49,9 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should export a single rule with a rule_id', async () => { - await createRule(supertest, log, getSimpleRule()); + const ruleToExport = getCustomQueryRuleParams(); + + await createRule(supertest, log, ruleToExport); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -61,14 +61,13 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200) .parse(binaryToString); - const bodySplitAndParsed = JSON.parse(body.toString().split(/\n/)[0]); - const bodyToTest = removeServerGeneratedProperties(bodySplitAndParsed); + const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); - expect(bodyToTest).to.eql(getSimpleRuleOutput()); + expect(exportedRule).toMatchObject(ruleToExport); }); - it('should export a exported count with a single rule_id', async () => { - await createRule(supertest, log, getSimpleRule()); + it('should have export summary reflecting a number of rules', async () => { + await createRule(supertest, log, getCustomQueryRuleParams()); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -78,30 +77,22 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200) .parse(binaryToString); - const bodySplitAndParsed = JSON.parse(body.toString().split(/\n/)[1]); + const exportSummary = JSON.parse(body.toString().split(/\n/)[1]); - expect(bodySplitAndParsed).to.eql({ + expect(exportSummary).toMatchObject({ exported_exception_list_count: 0, exported_exception_list_item_count: 0, exported_count: 1, exported_rules_count: 1, - missing_exception_list_item_count: 0, - missing_exception_list_items: [], - missing_exception_lists: [], - missing_exception_lists_count: 0, - missing_rules: [], - missing_rules_count: 0, - excluded_action_connection_count: 0, - excluded_action_connections: [], - exported_action_connector_count: 0, - missing_action_connection_count: 0, - missing_action_connections: [], }); }); it('should export exactly two rules given two rules', async () => { - await createRule(supertest, log, getSimpleRule('rule-1')); - await createRule(supertest, log, getSimpleRule('rule-2')); + const ruleToExport1 = getCustomQueryRuleParams({ rule_id: 'rule-1' }); + const ruleToExport2 = getCustomQueryRuleParams({ rule_id: 'rule-2' }); + + await createRule(supertest, log, ruleToExport1); + await createRule(supertest, log, ruleToExport2); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -111,15 +102,15 @@ export default ({ getService }: FtrProviderContext): void => { .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 exportedRule1 = JSON.parse(body.toString().split(/\n/)[0]); + const exportedRule2 = JSON.parse(body.toString().split(/\n/)[1]); - expect([firstRule, secondRule]).to.eql([ - getSimpleRuleOutput('rule-2'), - getSimpleRuleOutput('rule-1'), - ]); + expect([exportedRule1, exportedRule2]).toEqual( + expect.arrayContaining([ + expect.objectContaining(ruleToExport1), + expect.objectContaining(ruleToExport2), + ]) + ); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts index bf22875e23712..cbc6ddbf78328 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts @@ -5,19 +5,18 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { + createRule, + combineToNdJson, createSignalsIndex, deleteAllRules, deleteAllAlerts, - getSimpleRule, - getSimpleRuleAsNdjson, - getSimpleRuleOutput, - removeServerGeneratedProperties, - ruleToNdjson, + getCustomQueryRuleParams, + fetchRule, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -38,11 +37,13 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should set the response content types to be expected', async () => { + const ndjson = combineToNdJson(getCustomQueryRuleParams()); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); }); @@ -52,118 +53,113 @@ export default ({ getService }: FtrProviderContext): void => { .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.txt') + .attach('file', Buffer.from(''), 'rules.txt') .expect(400); - expect(body).to.eql({ + expect(body).toEqual({ status_code: 400, message: 'Invalid file extension .txt', }); }); it('should report that it imported a simple rule successfully', async () => { + const ndjson = combineToNdJson(getCustomQueryRuleParams()); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 1, rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should be able to read an imported rule back out correctly', async () => { + const ruleToImport = getCustomQueryRuleParams({ rule_id: 'rule-to-import' }); + const ndjson = combineToNdJson(ruleToImport); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const { body } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); + const importedRule = await fetchRule(supertest, { ruleId: 'rule-to-import' }); - const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql({ - ...getSimpleRuleOutput('rule-1', false), - output_index: '', - }); + expect(importedRule).toMatchObject(ruleToImport); }); it('should fail validation when importing a rule with malformed "from" params on the rules', async () => { - const stringifiedRule = JSON.stringify({ - from: 'now-3755555555555555.67s', - interval: '5m', - ...getSimpleRule('rule-1'), - }); - const fileNdJson = Buffer.from(stringifiedRule + '\n'); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + from: 'now-3755555555555555.67s', + interval: '5m', + }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', fileNdJson, 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body.errors[0].error.message).to.eql('from: Failed to parse date-math expression'); + expect(body.errors[0].error.message).toBe('from: Failed to parse date-math expression'); }); it('should fail validation when importing two rules and one has a malformed "from" params', async () => { - const stringifiedRule = JSON.stringify({ - from: 'now-3755555555555555.67s', - interval: '5m', - ...getSimpleRule('rule-1'), - }); - const stringifiedRule2 = JSON.stringify({ - ...getSimpleRule('rule-2'), - }); - const fileNdJson = Buffer.from([stringifiedRule, stringifiedRule2].join('\n')); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'malformed-rule', + from: 'now-3755555555555555.67s', + interval: '5m', + }), + getCustomQueryRuleParams({ + rule_id: 'good-rule', + }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', fileNdJson, 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); // should result in one success and a failure message - expect(body.success_count).to.eql(1); - expect(body.errors[0].error.message).to.eql('from: Failed to parse date-math expression'); + expect(body.success_count).toBe(1); + expect(body.errors[0].error.message).toBe('from: Failed to parse date-math expression'); }); it('should be able to import two rules', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + }), + getCustomQueryRuleParams({ + rule_id: 'rule-2', + }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 2, rules_count: 2, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); @@ -172,26 +168,25 @@ export default ({ getService }: FtrProviderContext): void => { // test to complete so at 10 rules completing in about 10 seconds // I figured this is enough to make sure the import route is doing its job. it('should be able to import 10 rules', async () => { - const ruleIds = new Array(10).fill(undefined).map((_, index) => `rule-${index}`); + const ndjson = combineToNdJson( + ...new Array(10).fill(0).map((_, i) => + getCustomQueryRuleParams({ + rule_id: `rule-${i}`, + }) + ) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(ruleIds, false), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 10, rules_count: 10, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); @@ -213,29 +208,45 @@ export default ({ getService }: FtrProviderContext): void => { // }); it('should NOT be able to import more than 10,000 rules', async () => { - const ruleIds = new Array(10001).fill(undefined).map((_, index) => `rule-${index}`); + const ndjson = combineToNdJson( + ...new Array(10001).fill(0).map((_, i) => + getCustomQueryRuleParams({ + rule_id: `rule-${i}`, + }) + ) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(ruleIds, false), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(500); - expect(body).to.eql({ + expect(body).toEqual({ status_code: 500, message: "Can't import more than 10000 rules", }); }); it('should report a conflict if there is an attempt to import two rules with the same rule_id', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + }), + getCustomQueryRuleParams({ + rule_id: 'rule-1', + }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { @@ -248,55 +259,51 @@ export default ({ getService }: FtrProviderContext): void => { success: false, success_count: 1, rules_count: 2, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should NOT report a conflict if there is an attempt to import two rules with the same rule_id and overwrite is set to true', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + }), + getCustomQueryRuleParams({ + rule_id: 'rule-1', + }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 1, rules_count: 2, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); + const ruleToImport = getCustomQueryRuleParams({ + rule_id: 'rule-1', + }); + + await createRule(supertest, log, ruleToImport); + + const ndjson = combineToNdJson(ruleToImport); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { @@ -309,210 +316,230 @@ export default ({ getService }: FtrProviderContext): void => { success: false, rules_count: 1, success_count: 0, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should NOT report a conflict if there is an attempt to import a rule with a rule_id that already exists and overwrite is set to true', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); + const ruleToImport = getCustomQueryRuleParams({ + rule_id: 'rule-1', + }); + + await createRule(supertest, log, ruleToImport); + + const ndjson = combineToNdJson(ruleToImport); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 1, rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should overwrite an existing rule if overwrite is set to true', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-to-overwrite', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-to-overwrite', + name: 'some other name', + }) + ); + await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const simpleRule = getSimpleRule('rule-1'); - simpleRule.name = 'some other name'; - const ndjson = ruleToNdjson(simpleRule); + const importedRule = await fetchRule(supertest, { ruleId: 'rule-to-overwrite' }); + + expect(importedRule).toMatchObject({ + name: 'some other name', + }); + }); + + it('should bump a revision when overwriting a rule', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-to-overwrite', + }) + ); + + const ruleBeforeOverwriting = await fetchRule(supertest, { ruleId: 'rule-to-overwrite' }); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-to-overwrite', + name: 'some other name', + }) + ); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', ndjson, 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const { body } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); + const ruleAfterOverwriting = await fetchRule(supertest, { ruleId: 'rule-to-overwrite' }); - const bodyToCompare = removeServerGeneratedProperties(body); - const ruleOutput = { - ...getSimpleRuleOutput('rule-1'), - output_index: '', - }; - ruleOutput.name = 'some other name'; - ruleOutput.revision = 0; - expect(bodyToCompare).to.eql(ruleOutput); + expect(ruleBeforeOverwriting).toMatchObject({ + revision: 0, + }); + expect(ruleAfterOverwriting).toMatchObject({ + revision: 1, + }); }); it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists, but still have some successes with other rules', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + }), + getCustomQueryRuleParams({ + rule_id: 'non-existing-rule-1', + }), + getCustomQueryRuleParams({ + rule_id: 'non-existing-rule-2', + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { - message: 'rule_id: "rule-1" already exists', + message: 'rule_id: "existing-rule" already exists', status_code: 409, }, - rule_id: 'rule-1', + rule_id: 'existing-rule', }, ], success: false, success_count: 2, rules_count: 3, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should report a mix of conflicts and a mix of successes', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') - .expect(200); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule-1', + }) + ); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule-2', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule-1', + }), + getCustomQueryRuleParams({ + rule_id: 'existing-rule-2', + }), + getCustomQueryRuleParams({ + rule_id: 'non-existing-rule', + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { - message: 'rule_id: "rule-1" already exists', + message: 'rule_id: "existing-rule-1" already exists', status_code: 409, }, - rule_id: 'rule-1', + rule_id: 'existing-rule-1', }, { error: { - message: 'rule_id: "rule-2" already exists', + message: 'rule_id: "existing-rule-2" already exists', status_code: 409, }, - rule_id: 'rule-2', + rule_id: 'existing-rule-2', }, ], success: false, success_count: 1, rules_count: 3, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should be able to correctly read back a mixed import of different rules even if some cause conflicts', async () => { - const getRuleOutput = (name: string) => ({ - ...getSimpleRuleOutput(name), - output_index: '', + const existingRule1 = getCustomQueryRuleParams({ + rule_id: 'existing-rule-1', }); - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') - .expect(200); + const existingRule2 = getCustomQueryRuleParams({ + rule_id: 'existing-rule-2', + }); + const ruleToImportSuccessfully = getCustomQueryRuleParams({ + rule_id: 'non-existing-rule', + }); + + await createRule(supertest, log, existingRule1); + await createRule(supertest, log, existingRule2); + + const ndjson = combineToNdJson(existingRule1, existingRule2, ruleToImportSuccessfully); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') - .expect(200); - - const { body: bodyOfRule1 } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - - const { body: bodyOfRule2 } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-2`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - - const { body: bodyOfRule3 } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-3`) - .set('elastic-api-version', '2023-10-31') - .send() + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const bodyToCompareOfRule1 = removeServerGeneratedProperties(bodyOfRule1); - const bodyToCompareOfRule2 = removeServerGeneratedProperties(bodyOfRule2); - const bodyToCompareOfRule3 = removeServerGeneratedProperties(bodyOfRule3); + const rule1 = await fetchRule(supertest, { ruleId: 'existing-rule-1' }); + const rule2 = await fetchRule(supertest, { ruleId: 'existing-rule-2' }); + const rule3 = await fetchRule(supertest, { ruleId: 'non-existing-rule' }); - expect([bodyToCompareOfRule1, bodyToCompareOfRule2, bodyToCompareOfRule3]).to.eql([ - getRuleOutput('rule-1'), - getRuleOutput('rule-2'), - getRuleOutput('rule-3'), - ]); + expect(rule1).toMatchObject(existingRule1); + expect(rule2).toMatchObject(existingRule2); + expect(rule3).toMatchObject(ruleToImportSuccessfully); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules_with_overwrite.ts b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules_with_overwrite.ts new file mode 100644 index 0000000000000..39eee5f2dda0b --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules_with_overwrite.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; + +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createRule, + deleteAllRules, + combineToNdJson, + getCustomQueryRuleParams, + fetchRule, +} from '../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @skipInQA import_rules with rule overwrite set to "true"', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + }); + + it('DOES NOT report a conflict if there is an attempt to import two rules with the same rule_id', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ rule_id: 'rule-1', name: 'Rule 1' }), + getCustomQueryRuleParams({ rule_id: 'rule-1', name: 'Rule 2' }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 2, + }); + + const importedRule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(importedRule.name).toBe('Rule 2'); + }); + + it('DOES NOT report a conflict if there is an attempt to import a rule twice', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'imported-rule', + name: 'Imported rule', + }) + ); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + }); + }); + + it('overwrites an existing rule', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + name: 'Existing rule', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + name: 'Imported rule', + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + }); + + const importedRule = await fetchRule(supertest, { ruleId: 'existing-rule' }); + + expect(importedRule.name).toBe('Imported rule'); + }); + + /** + * Existing rule may have nullable fields set to a value (e.g. `timestamp_override` is set to `some.value`) but + * a rule to import doesn't have these fields set (e.g. `timestamp_override` is NOT present at all in the ndjson file). + * We expect the updated rule won't have such fields preserved (e.g. `timestamp_override` will be removed). + */ + it('ensures overwritten rule DOES NOT preserve fields missed in the imported rule', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + rule_name_override: 'some name', + timestamp_override: 'some.value', + timeline_id: 'some id', + timeline_title: 'some title', + outcome: 'exactMatch', + alias_target_id: 'some id', + license: 'some license', + note: 'some notes', + building_block_type: 'some type', + output_index: 'some-index', + namespace: 'some-namespace', + meta: { + some: 'field', + }, + investigation_fields: { field_names: ['a', 'b', 'c'] }, + throttle: 'no_actions', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + namespace: 'abc', + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + }); + + const importedRule = await fetchRule(supertest, { ruleId: 'existing-rule' }); + + expect(importedRule).toMatchObject({ + rule_id: 'existing-rule', + output_index: '', + }); + expect(importedRule).toEqual( + expect.not.objectContaining({ + rule_name_override: expect.anything(), + timestamp_override: expect.anything(), + timeline_id: expect.anything(), + timeline_title: expect.anything(), + outcome: expect.anything(), + alias_target_id: expect.anything(), + license: expect.anything(), + note: expect.anything(), + building_block_type: expect.anything(), + namespace: expect.anything(), + meta: expect.anything(), + investigation_fields: expect.anything(), + throttle: expect.anything(), + }) + ); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts index d03cb681dd62c..6cf48386120de 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts @@ -30,7 +30,10 @@ import { getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, getRuleSOById, + getCustomQueryRuleParams, createRuleThroughAlertingEndpoint, + getWebHookConnectorParams, + createConnector, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -425,15 +428,11 @@ export default ({ getService }: FtrProviderContext): void => { */ describe('legacy_notification_system', () => { it('should be able to export 1 legacy action on 1 rule', async () => { - // create an action - const { body: hookAction } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getWebHookAction()) - .expect(200); + const webHookConnectorParams = getWebHookConnectorParams(); + const webHookConnectorId = await createConnector(supertest, webHookConnectorParams); // create a rule without actions - const rule = await createRule(supertest, log, getSimpleRule('rule-1')); + const rule = await createRule(supertest, log, getCustomQueryRuleParams()); // attach the legacy notification await supertest @@ -445,13 +444,13 @@ export default ({ getService }: FtrProviderContext): void => { interval: '1h', actions: [ { - id: hookAction.id, + id: webHookConnectorId, group: 'default', params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', }, - actionTypeId: hookAction.actionTypeId, + actionTypeId: webHookConnectorParams.connector_type_id, }, ], }) @@ -466,13 +465,14 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200) .parse(binaryToString); - const outputRule1: ReturnType = { - ...getSimpleRuleOutput('rule-1'), + const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); + + expect(exportedRule).toMatchObject({ actions: [ { group: 'default', - id: hookAction.id, - action_type_id: hookAction.actionTypeId, + id: webHookConnectorId, + action_type_id: webHookConnectorParams.connector_type_id, params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', @@ -480,30 +480,16 @@ export default ({ getService }: FtrProviderContext): void => { frequency: { summary: true, throttle: '1h', notifyWhen: 'onThrottleInterval' }, }, ], - }; - const firstRuleParsed = JSON.parse(body.toString().split(/\n/)[0]); - const firstRule = removeServerGeneratedProperties(firstRuleParsed); - - expect(firstRule).toEqual(outputRule1); + }); }); it('should be able to export 2 legacy actions on 1 rule', async () => { - // create 1st action/connector - const { body: hookAction1 } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getWebHookAction()) - .expect(200); - - // create 2nd action/connector - const { body: hookAction2 } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getWebHookAction()) - .expect(200); + const webHookConnectorParams = getWebHookConnectorParams(); + const webHookConnectorId1 = await createConnector(supertest, webHookConnectorParams); + const webHookConnectorId2 = await createConnector(supertest, webHookConnectorParams); // create a rule without actions - const rule = await createRule(supertest, log, getSimpleRule('rule-1')); + const rule = await createRule(supertest, log, getCustomQueryRuleParams()); // attach the legacy notification with actions await supertest @@ -515,22 +501,22 @@ export default ({ getService }: FtrProviderContext): void => { interval: '1h', actions: [ { - id: hookAction1.id, + id: webHookConnectorId1, group: 'default', params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', }, - actionTypeId: hookAction1.actionTypeId, + actionTypeId: webHookConnectorParams.connector_type_id, }, { - id: hookAction2.id, + id: webHookConnectorId2, group: 'default', params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', }, - actionTypeId: hookAction2.actionTypeId, + actionTypeId: webHookConnectorParams.connector_type_id, }, ], }) @@ -545,13 +531,14 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200) .parse(binaryToString); - const outputRule1: ReturnType = { - ...getSimpleRuleOutput('rule-1'), + const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); + + expect(exportedRule).toMatchObject({ actions: [ { group: 'default', - id: hookAction1.id, - action_type_id: hookAction1.actionTypeId, + id: webHookConnectorId1, + action_type_id: webHookConnectorParams.connector_type_id, params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', @@ -560,8 +547,8 @@ export default ({ getService }: FtrProviderContext): void => { }, { group: 'default', - id: hookAction2.id, - action_type_id: hookAction2.actionTypeId, + id: webHookConnectorId2, + action_type_id: webHookConnectorParams.connector_type_id, params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', @@ -569,31 +556,25 @@ export default ({ getService }: FtrProviderContext): void => { frequency: { summary: true, throttle: '1h', notifyWhen: 'onThrottleInterval' }, }, ], - }; - const firstRuleParsed = JSON.parse(body.toString().split(/\n/)[0]); - const firstRule = removeServerGeneratedProperties(firstRuleParsed); - - expect(firstRule).toEqual(outputRule1); + }); }); it('should be able to export 2 legacy actions on 2 rules', async () => { - // create 1st action/connector - const { body: hookAction1 } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getWebHookAction()) - .expect(200); - - // create 2nd action/connector - const { body: hookAction2 } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getWebHookAction()) - .expect(200); + const webHookConnectorParams = getWebHookConnectorParams(); + const webHookConnectorId1 = await createConnector(supertest, webHookConnectorParams); + const webHookConnectorId2 = await createConnector(supertest, webHookConnectorParams); // create 2 rules without actions - const rule1 = await createRule(supertest, log, getSimpleRule('rule-1')); - const rule2 = await createRule(supertest, log, getSimpleRule('rule-2')); + const rule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1' }) + ); + const rule2 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2' }) + ); // attach the legacy notification with actions to the first rule await supertest @@ -605,22 +586,22 @@ export default ({ getService }: FtrProviderContext): void => { interval: '1h', actions: [ { - id: hookAction1.id, + id: webHookConnectorId1, group: 'default', params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', }, - actionTypeId: hookAction1.actionTypeId, + actionTypeId: webHookConnectorParams.connector_type_id, }, { - id: hookAction2.id, + id: webHookConnectorId2, group: 'default', params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', }, - actionTypeId: hookAction2.actionTypeId, + actionTypeId: webHookConnectorParams.connector_type_id, }, ], }) @@ -636,22 +617,22 @@ export default ({ getService }: FtrProviderContext): void => { interval: '1h', actions: [ { - id: hookAction1.id, + id: webHookConnectorId1, group: 'default', params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', }, - actionTypeId: hookAction1.actionTypeId, + actionTypeId: webHookConnectorParams.connector_type_id, }, { - id: hookAction2.id, + id: webHookConnectorId2, group: 'default', params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', }, - actionTypeId: hookAction2.actionTypeId, + actionTypeId: webHookConnectorParams.connector_type_id, }, ], }) @@ -666,13 +647,15 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200) .parse(binaryToString); - const outputRule1: ReturnType = { - ...getSimpleRuleOutput('rule-1'), + const exportedRule1 = JSON.parse(body.toString().split(/\n/)[0]); + const exportedRule2 = JSON.parse(body.toString().split(/\n/)[1]); + + expect(exportedRule1).toMatchObject({ actions: [ { group: 'default', - id: hookAction1.id, - action_type_id: hookAction1.actionTypeId, + id: webHookConnectorId1, + action_type_id: webHookConnectorParams.connector_type_id, params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', @@ -681,8 +664,8 @@ export default ({ getService }: FtrProviderContext): void => { }, { group: 'default', - id: hookAction2.id, - action_type_id: hookAction2.actionTypeId, + id: webHookConnectorId2, + action_type_id: webHookConnectorParams.connector_type_id, params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', @@ -690,15 +673,13 @@ export default ({ getService }: FtrProviderContext): void => { frequency: { summary: true, throttle: '1h', notifyWhen: 'onThrottleInterval' }, }, ], - }; - - const outputRule2: ReturnType = { - ...getSimpleRuleOutput('rule-2'), + }); + expect(exportedRule2).toMatchObject({ actions: [ { group: 'default', - id: hookAction1.id, - action_type_id: hookAction1.actionTypeId, + id: webHookConnectorId1, + action_type_id: webHookConnectorParams.connector_type_id, params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', @@ -707,8 +688,8 @@ export default ({ getService }: FtrProviderContext): void => { }, { group: 'default', - id: hookAction2.id, - action_type_id: hookAction2.actionTypeId, + id: webHookConnectorId2, + action_type_id: webHookConnectorParams.connector_type_id, params: { message: 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', @@ -716,14 +697,7 @@ export default ({ getService }: FtrProviderContext): void => { frequency: { summary: true, throttle: '1h', notifyWhen: 'onThrottleInterval' }, }, ], - }; - 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); - - expect(firstRule).toEqual(outputRule2); - expect(secondRule).toEqual(outputRule1); + }); }); }); }); @@ -742,11 +716,15 @@ export default ({ getService }: FtrProviderContext): void => { supertest, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray() ); - await createRule(supertest, log, { - ...getSimpleRule('rule-with-investigation-field'), - name: 'Test investigation fields object', - investigation_fields: { field_names: ['host.name'] }, - }); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-with-investigation-field', + name: 'Test investigation fields object', + investigation_fields: { field_names: ['host.name'] }, + }) + ); }); afterEach(async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_connectors.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_connectors.ts index 0dcda3af45510..9b027fd023892 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_connectors.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_connectors.ts @@ -6,7 +6,6 @@ */ import expect from 'expect'; - import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { createConnector, deleteConnector, getConnector } from '../../utils/connectors'; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_export_rules.ts index aee267db951d5..e83b19acf4848 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_export_rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; import { @@ -25,8 +25,8 @@ import { createSignalsIndex, deleteAllRules, deleteAllAlerts, - getSimpleRule, deleteAllExceptions, + getCustomQueryRuleParams, } from '../../utils'; import { createUserAndRole, deleteUserAndRole } from '../../../common/services/security_solution'; @@ -86,7 +86,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(exceptionItem.comments).to.eql([ + expect(exceptionItem.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItem.comments[0].created_at}`, @@ -95,17 +95,20 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); - await createRule(supertest, log, { - ...getSimpleRule(), - exceptions_list: [ - { - id: exceptionBody.id, - list_id: exceptionBody.list_id, - type: exceptionBody.type, - namespace_type: exceptionBody.namespace_type, - }, - ], - }); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + exceptions_list: [ + { + id: exceptionBody.id, + list_id: exceptionBody.list_id, + type: exceptionBody.type, + namespace_type: exceptionBody.namespace_type, + }, + ], + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -130,7 +133,7 @@ export default ({ getService }: FtrProviderContext): void => { // NOTE: Existing comment is uploaded successfully // however, the meta now reflects who imported it, // not who created the initial comment - expect(exceptionItemFind2.comments).to.eql([ + expect(exceptionItemFind2.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItemFind2.comments[0].created_at}`, @@ -165,7 +168,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(exceptionItem.comments).to.eql([ + expect(exceptionItem.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItem.comments[0].created_at}`, @@ -174,17 +177,20 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); - await createRule(supertest, log, { - ...getSimpleRule(), - exceptions_list: [ - { - id: exceptionBody.id, - list_id: exceptionBody.list_id, - type: exceptionBody.type, - namespace_type: exceptionBody.namespace_type, - }, - ], - }); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + exceptions_list: [ + { + id: exceptionBody.id, + list_id: exceptionBody.list_id, + type: exceptionBody.type, + namespace_type: exceptionBody.namespace_type, + }, + ], + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -209,7 +215,7 @@ export default ({ getService }: FtrProviderContext): void => { // NOTE: Existing comment is uploaded successfully // however, the meta now reflects who imported it, // not who created the initial comment - expect(exceptionItemFind2.comments).to.eql([ + expect(exceptionItemFind2.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItemFind2.comments[0].created_at}`, @@ -253,7 +259,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(exceptionItem.comments).to.eql([ + expect(exceptionItem.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItem.comments[0].created_at}`, @@ -262,17 +268,20 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); - await createRule(supertest, log, { - ...getSimpleRule(), - exceptions_list: [ - { - id: exceptionBody.id, - list_id: exceptionBody.list_id, - type: exceptionBody.type, - namespace_type: exceptionBody.namespace_type, - }, - ], - }); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + exceptions_list: [ + { + id: exceptionBody.id, + list_id: exceptionBody.list_id, + type: exceptionBody.type, + namespace_type: exceptionBody.namespace_type, + }, + ], + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -297,7 +306,7 @@ export default ({ getService }: FtrProviderContext): void => { // NOTE: Existing comment is uploaded successfully // however, the meta now reflects who imported it, // not who created the initial comment - expect(exceptionItemFind2.comments).to.eql([ + expect(exceptionItemFind2.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItemFind2.comments[0].created_at}`, @@ -332,7 +341,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(exceptionItem.comments).to.eql([ + expect(exceptionItem.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItem.comments[0].created_at}`, @@ -341,17 +350,20 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); - await createRule(supertest, log, { - ...getSimpleRule(), - exceptions_list: [ - { - id: exceptionBody.id, - list_id: exceptionBody.list_id, - type: exceptionBody.type, - namespace_type: exceptionBody.namespace_type, - }, - ], - }); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + exceptions_list: [ + { + id: exceptionBody.id, + list_id: exceptionBody.list_id, + type: exceptionBody.type, + namespace_type: exceptionBody.namespace_type, + }, + ], + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_export`) @@ -376,7 +388,7 @@ export default ({ getService }: FtrProviderContext): void => { // NOTE: Existing comment is uploaded successfully // however, the meta now reflects who imported it, // not who created the initial comment - expect(exceptionItemFind2.comments).to.eql([ + expect(exceptionItemFind2.comments).toEqual([ { comment: 'this exception item rocks', created_at: `${exceptionItemFind2.comments[0].created_at}`, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts index 6517c46bddcaf..c645006203c59 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts @@ -5,18 +5,12 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; -import { - InvestigationFields, - QueryRuleCreateProps, - RuleCreateProps, -} from '@kbn/security-solution-plugin/common/api/detection_engine'; import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; import { getCreateExceptionListMinimalSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_schema.mock'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { - toNdJsonString, getImportExceptionsListItemSchemaMock, getImportExceptionsListSchemaMock, getImportExceptionsListItemNewerVersionSchemaMock, @@ -25,20 +19,16 @@ import { ROLES } from '@kbn/security-solution-plugin/common/test'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { deleteAllRules, - getSimpleRule, - getSimpleRuleAsNdjson, - getRulesAsNdjson, - getSimpleRuleOutput, getThresholdRuleForSignalTesting, - getWebHookAction, - removeServerGeneratedProperties, - ruleToNdjson, - createLegacyRuleAction, - getLegacyActionSO, createRule, - getRule, - getRuleSOById, deleteAllExceptions, + createConnector, + getWebHookConnectorParams, + combineToNdJson, + fetchRule, + getCustomQueryRuleParams, + getThresholdRuleForAlertTesting, + checkInvestigationFieldSoValue, } from '../../utils'; import { createUserAndRole, deleteUserAndRole } from '../../../common/services/security_solution'; @@ -98,9 +88,9 @@ const getImportRuleBuffer = (connectorId: string) => { }, ], }; - const rule1String = JSON.stringify(rule1); - const buffer = Buffer.from(`${rule1String}\n`); - return buffer; + const ndjson = combineToNdJson(rule1); + + return Buffer.from(ndjson); }; const getImportRuleWithConnectorsBuffer = (connectorId: string) => { const rule1 = { @@ -175,22 +165,9 @@ const getImportRuleWithConnectorsBuffer = (connectorId: string) => { migrationVersion: { action: '8.3.0' }, coreMigrationVersion: '8.7.0', }; - const rule1String = JSON.stringify(rule1); - const connectorString = JSON.stringify(connector); - const buffer = Buffer.from(`${rule1String}\n${connectorString}`); - return buffer; -}; + const ndjson = combineToNdJson(rule1, connector); -export const getSimpleRuleAsNdjsonWithLegacyInvestigationField = ( - ruleIds: string[], - enabled = false, - overwrites: Partial -): Buffer => { - const stringOfRules = ruleIds.map((ruleId) => { - const simpleRule = { ...getSimpleRule(ruleId, enabled), ...overwrites }; - return JSON.stringify(simpleRule); - }); - return Buffer.from(stringOfRules.join('\n')); + return Buffer.from(ndjson); }; // eslint-disable-next-line import/no-default-export @@ -214,23 +191,23 @@ export default ({ getService }: FtrProviderContext): void => { await deleteUserAndRole(getService, ROLES.hunter_no_actions); await deleteUserAndRole(getService, ROLES.hunter); }); + it('should successfully import rules without actions when user has no actions privileges', async () => { + const ndjson = combineToNdJson(getCustomQueryRuleParams()); + const { body } = await supertestWithoutAuth .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .auth(ROLES.hunter_no_actions, 'changeme') .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 1, rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: true, action_connectors_success_count: 0, action_connectors_errors: [], @@ -239,54 +216,47 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should not import rules with actions when user has "read" actions privileges', 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 = { - ...getSimpleRule('rule-1'), - actions: [ - { - group: 'default', - id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', - action_type_id: hookAction.actionTypeId, - params: {}, + const connectorId = await createConnector(supertest, getWebHookConnectorParams()); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-with-actions', + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: connectorId, + params: {}, + }, + ], + }), + { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, }, - ], - }; - const ruleWithConnector = { - id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', - type: 'action', - updated_at: '2023-01-25T14:35:52.852Z', - created_at: '2023-01-25T14:35:52.852Z', - version: 'WzUxNTksMV0=', - attributes: { - actionTypeId: '.webhook', - name: 'webhook', - isMissingSecrets: true, - config: {}, - secrets: {}, - }, - references: [], - migrationVersion: { action: '8.3.0' }, - coreMigrationVersion: '8.7.0', - }; + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + } + ); const { body } = await supertestWithoutAuth .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .auth(ROLES.hunter, 'changeme') .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { @@ -300,9 +270,6 @@ export default ({ getService }: FtrProviderContext): void => { success: false, success_count: 0, rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: false, action_connectors_success_count: 0, action_connectors_errors: [ @@ -318,54 +285,49 @@ export default ({ getService }: FtrProviderContext): void => { action_connectors_warnings: [], }); }); + it('should not import rules with actions when a user has no actions privileges', 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 = { - ...getSimpleRule('rule-1'), - actions: [ - { - group: 'default', - id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', - action_type_id: hookAction.actionTypeId, - params: {}, + const connectorId = await createConnector(supertest, getWebHookConnectorParams()); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-with-actions', + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: connectorId, + params: {}, + }, + ], + }), + { + id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, }, - ], - }; - const ruleWithConnector = { - id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', - type: 'action', - updated_at: '2023-01-25T14:35:52.852Z', - created_at: '2023-01-25T14:35:52.852Z', - version: 'WzUxNTksMV0=', - attributes: { - actionTypeId: '.webhook', - name: 'webhook', - isMissingSecrets: true, - config: {}, - secrets: {}, - }, - references: [], - migrationVersion: { action: '8.3.0' }, - coreMigrationVersion: '8.7.0', - }; + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + } + ); const { body } = await supertestWithoutAuth .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .auth(ROLES.hunter_no_actions, 'changeme') .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + + expect(body).toMatchObject({ success: false, success_count: 0, errors: [ @@ -379,9 +341,6 @@ export default ({ getService }: FtrProviderContext): void => { }, ], rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: false, action_connectors_success_count: 0, action_connectors_errors: [ @@ -401,22 +360,24 @@ export default ({ getService }: FtrProviderContext): void => { describe('threshold validation', () => { it('should result in partial success if no threshold-specific fields are provided', async () => { - const { threshold, ...rule } = getThresholdRuleForSignalTesting(['*']); + const { threshold, ...rule } = getThresholdRuleForAlertTesting(['*']); + const ndjson = combineToNdJson(rule); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', ruleToNdjson(rule as RuleCreateProps), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body.errors[0]).to.eql({ + expect(body.errors[0]).toEqual({ rule_id: '(unknown id)', error: { status_code: 400, message: 'threshold: Required' }, }); }); it('should result in partial success if more than 3 threshold fields', async () => { - const baseRule = getThresholdRuleForSignalTesting(['*']); + const baseRule = getThresholdRuleForAlertTesting(['*']); const rule = { ...baseRule, threshold: { @@ -424,14 +385,16 @@ export default ({ getService }: FtrProviderContext): void => { field: ['field-1', 'field-2', 'field-3', 'field-4'], }, }; + const ndjson = combineToNdJson(rule); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', ruleToNdjson(rule), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body.errors[0]).to.eql({ + expect(body.errors[0]).toEqual({ rule_id: '(unknown id)', error: { message: 'Number of fields must be 3 or less', @@ -449,14 +412,16 @@ export default ({ getService }: FtrProviderContext): void => { value: 0, }, }; + const ndjson = combineToNdJson(rule); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', ruleToNdjson(rule), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body.errors[0]).to.eql({ + expect(body.errors[0]).toEqual({ rule_id: '(unknown id)', error: { message: 'threshold.value: Number must be greater than or equal to 1', @@ -479,14 +444,16 @@ export default ({ getService }: FtrProviderContext): void => { ], }, }; + const ndjson = combineToNdJson(rule); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', ruleToNdjson(rule), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body.errors[0]).to.eql({ + expect(body.errors[0]).toEqual({ rule_id: '(unknown id)', error: { message: 'Cardinality of a field that is being aggregated on is always 1', @@ -498,8 +465,8 @@ export default ({ getService }: FtrProviderContext): void => { describe('forward compatibility', () => { it('should remove any extra rule fields when importing', async () => { - const rule: QueryRuleCreateProps = { - ...getSimpleRule('rule-1'), + const rule = getCustomQueryRuleParams({ + rule_id: 'rule-1', extraField: true, risk_score_mapping: [ { @@ -540,37 +507,36 @@ export default ({ getService }: FtrProviderContext): void => { // @ts-expect-error extraField: true, }, - }; - const payload = Buffer.from(JSON.stringify(rule)); + }); + const ndjson = combineToNdJson(rule); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', payload, 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const { body } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); + const importedRule = await fetchRule(supertest, { ruleId: 'rule-1' }); - expect(Object.hasOwn(body, 'extraField')).to.eql(false); - expect(Object.hasOwn(body.risk_score_mapping[0], 'extraField')).to.eql(false); - expect(Object.hasOwn(body.severity_mapping[0], 'extraField')).to.eql(false); - expect(Object.hasOwn(body.threat[0], 'extraField')).to.eql(false); - expect(Object.hasOwn(body.threat[0].tactic, 'extraField')).to.eql(false); - expect(Object.hasOwn(body.investigation_fields, 'extraField')).to.eql(false); + expect(Object.hasOwn(importedRule, 'extraField')).toBeFalsy(); + expect(Object.hasOwn(importedRule.risk_score_mapping[0], 'extraField')).toBeFalsy(); + expect(Object.hasOwn(importedRule.severity_mapping[0], 'extraField')).toBeFalsy(); + expect(Object.hasOwn(importedRule.threat[0], 'extraField')).toBeFalsy(); + expect(Object.hasOwn(importedRule.threat[0].tactic, 'extraField')).toBeFalsy(); + expect(Object.hasOwn(importedRule.investigation_fields!, 'extraField')).toBeFalsy(); }); }); describe('importing rules with an index', () => { it('should set the response content types to be expected', async () => { + const ndjson = combineToNdJson(getCustomQueryRuleParams()); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); }); @@ -580,91 +546,84 @@ export default ({ getService }: FtrProviderContext): void => { .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.txt') + .attach('file', Buffer.from(''), 'rules.txt') .expect(400); - expect(body).to.eql({ + expect(body).toEqual({ status_code: 400, message: 'Invalid file extension .txt', }); }); it('should report that it imported a simple rule successfully', async () => { + const ndjson = combineToNdJson(getCustomQueryRuleParams()); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 1, rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should be able to read an imported rule back out correctly', async () => { + const ruleToImport = getCustomQueryRuleParams({ rule_id: 'rule-1' }); + const ndjson = combineToNdJson(ruleToImport); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const { body } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); + const importedRule = await fetchRule(supertest, { ruleId: 'rule-1' }); - const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql({ - ...getSimpleRuleOutput('rule-1', false), - output_index: '', - }); + expect(importedRule).toMatchObject(ruleToImport); }); it('should be able to import two rules', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ rule_id: 'rule-1' }), + getCustomQueryRuleParams({ rule_id: 'rule-2' }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [], success: true, success_count: 2, rules_count: 2, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should report a conflict if there is an attempt to import two rules with the same rule_id', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ rule_id: 'rule-1' }), + getCustomQueryRuleParams({ rule_id: 'rule-1' }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { @@ -677,55 +636,24 @@ export default ({ getService }: FtrProviderContext): void => { success: false, success_count: 1, rules_count: 2, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); - it('should NOT report a conflict if there is an attempt to import two rules with the same rule_id and overwrite is set to true', async () => { - const { body } = await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-1']), 'rules.ndjson') - .expect(200); + it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists', async () => { + const existingRule = getCustomQueryRuleParams({ rule_id: 'rule-1' }); - expect(body).to.eql({ - errors: [], - success: true, - success_count: 1, - rules_count: 2, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], - }); - }); + await createRule(supertest, log, existingRule); - it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); + const ndjson = combineToNdJson(existingRule); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { @@ -738,273 +666,165 @@ export default ({ getService }: FtrProviderContext): void => { success: false, success_count: 0, rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); - it('should NOT report a conflict if there is an attempt to import a rule with a rule_id that already exists and overwrite is set to true', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); - - const { body } = await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); - - expect(body).to.eql({ - errors: [], - success: true, - success_count: 1, - rules_count: 1, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], - }); - }); - - it('should overwrite an existing rule if overwrite is set to true', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); - - const simpleRule = getSimpleRule('rule-1'); - simpleRule.name = 'some other name'; - const ndjson = ruleToNdjson(simpleRule); - - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', ndjson, 'rules.ndjson') - .expect(200); - - const { body } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - - const bodyToCompare = removeServerGeneratedProperties(body); - const ruleOutput = { - ...getSimpleRuleOutput('rule-1'), - output_index: '', - }; - ruleOutput.name = 'some other name'; - ruleOutput.revision = 0; - expect(bodyToCompare).to.eql(ruleOutput); - }); - - it('should migrate legacy actions in existing rule if overwrite is set to true', async () => { - const simpleRule = getSimpleRule('rule-1'); - - const [connector, createdRule] = await Promise.all([ - supertest - .post(`/api/actions/connector`) - .set('kbn-xsrf', 'foo') - .send({ - name: 'My action', - connector_type_id: '.slack', - secrets: { - webhookUrl: 'http://localhost:1234', - }, - }), - createRule(supertest, log, simpleRule), - ]); - await createLegacyRuleAction(supertest, createdRule.id, connector.body.id); - - // check for legacy sidecar action - const sidecarActionsResults = await getLegacyActionSO(es); - expect(sidecarActionsResults.hits.hits.length).to.eql(1); - expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).to.eql( - createdRule.id + it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists, but still have some successes with other rules', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + }) ); - simpleRule.name = 'some other name'; - const ndjson = ruleToNdjson(simpleRule); - - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', ndjson, 'rules.ndjson') - .expect(200); - - // legacy sidecar action should be gone - const sidecarActionsPostResults = await getLegacyActionSO(es); - expect(sidecarActionsPostResults.hits.hits.length).to.eql(0); - }); - - it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists, but still have some successes with other rules', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') - .expect(200); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + }), + getCustomQueryRuleParams({ + rule_id: 'non-existing-rule-1', + }), + getCustomQueryRuleParams({ + rule_id: 'non-existing-rule-2', + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { - message: 'rule_id: "rule-1" already exists', + message: 'rule_id: "existing-rule" already exists', status_code: 409, }, - rule_id: 'rule-1', + rule_id: 'existing-rule', }, ], success: false, success_count: 2, rules_count: 3, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should report a mix of conflicts and a mix of successes', async () => { - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') - .expect(200); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule-1', + }) + ); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule-2', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule-1', + }), + getCustomQueryRuleParams({ + rule_id: 'existing-rule-2', + }), + getCustomQueryRuleParams({ + rule_id: 'non-existing-rule', + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ errors: [ { error: { - message: 'rule_id: "rule-1" already exists', + message: 'rule_id: "existing-rule-1" already exists', status_code: 409, }, - rule_id: 'rule-1', + rule_id: 'existing-rule-1', }, { error: { - message: 'rule_id: "rule-2" already exists', + message: 'rule_id: "existing-rule-2" already exists', status_code: 409, }, - rule_id: 'rule-2', + rule_id: 'existing-rule-2', }, ], success: false, success_count: 1, rules_count: 3, - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should be able to correctly read back a mixed import of different rules even if some cause conflicts', async () => { - const simpleRuleOutput = (ruleName: string) => ({ - ...getSimpleRuleOutput(ruleName), - output_index: '', + const existingRule1 = getCustomQueryRuleParams({ + rule_id: 'existing-rule-1', + }); + const existingRule2 = getCustomQueryRuleParams({ + rule_id: 'existing-rule-2', + }); + const ruleToImportSuccessfully = getCustomQueryRuleParams({ + rule_id: 'non-existing-rule', }); - await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') - .expect(200); + await createRule(supertest, log, existingRule1); + await createRule(supertest, log, existingRule2); + + const ndjson = combineToNdJson(existingRule1, existingRule2, ruleToImportSuccessfully); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const { body: bodyOfRule1 } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); + const rule1 = await fetchRule(supertest, { ruleId: 'existing-rule-1' }); + const rule2 = await fetchRule(supertest, { ruleId: 'existing-rule-2' }); + const rule3 = await fetchRule(supertest, { ruleId: 'non-existing-rule' }); - const { body: bodyOfRule2 } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-2`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - - const { body: bodyOfRule3 } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-3`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - - const bodyToCompareOfRule1 = removeServerGeneratedProperties(bodyOfRule1); - const bodyToCompareOfRule2 = removeServerGeneratedProperties(bodyOfRule2); - const bodyToCompareOfRule3 = removeServerGeneratedProperties(bodyOfRule3); - - expect([bodyToCompareOfRule1, bodyToCompareOfRule2, bodyToCompareOfRule3]).to.eql([ - simpleRuleOutput('rule-1'), - simpleRuleOutput('rule-2'), - simpleRuleOutput('rule-3'), - ]); + expect(rule1).toMatchObject(existingRule1); + expect(rule2).toMatchObject(existingRule2); + expect(rule3).toMatchObject(ruleToImportSuccessfully); }); it('should give single connector error back if we have a single connector error message', async () => { - const simpleRule: ReturnType = { - ...getSimpleRule('rule-1'), - actions: [ - { - group: 'default', - id: '123', - action_type_id: '456', - params: {}, - }, - ], - }; + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + actions: [ + { + group: 'default', + id: '123', + action_type_id: '456', + params: {}, + }, + ], + }) + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ success: false, success_count: 0, rules_count: 1, @@ -1018,9 +838,6 @@ export default ({ getService }: FtrProviderContext): void => { }, }, ], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: false, action_connectors_success_count: 0, action_connectors_warnings: [], @@ -1036,54 +853,50 @@ export default ({ getService }: FtrProviderContext): void => { ], }); }); + it('should give single connector warning back if we have a single connector missing secret', async () => { - const simpleRule: ReturnType = { - ...getSimpleRule('rule-1'), - actions: [ - { - group: 'default', - id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', - action_type_id: '.webhook', - params: {}, + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', + action_type_id: '.webhook', + params: {}, + }, + ], + }), + { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, }, - ], - }; - const ruleWithConnector = { - id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', - type: 'action', - updated_at: '2023-01-25T14:35:52.852Z', - created_at: '2023-01-25T14:35:52.852Z', - version: 'WzUxNTksMV0=', - attributes: { - actionTypeId: '.webhook', - name: 'webhook', - isMissingSecrets: true, - config: {}, - secrets: {}, - }, - references: [], - migrationVersion: { action: '8.3.0' }, - coreMigrationVersion: '8.7.0', - }; + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + } + ); + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, errors: [], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: true, action_connectors_success_count: 1, action_connectors_warnings: [ @@ -1099,134 +912,108 @@ export default ({ getService }: FtrProviderContext): void => { }); 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 = { - ...getSimpleRule('rule-1'), - actions: [ - { - group: 'default', - id: hookAction.id, - action_type_id: hookAction.actionTypeId, - params: {}, - }, - ], - }; + const webHookConnectorParams = getWebHookConnectorParams(); + const connectorId = await createConnector(supertest, webHookConnectorParams); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + actions: [ + { + group: 'default', + id: connectorId, + action_type_id: webHookConnectorParams.connector_type_id, + params: {}, + }, + ], + }) + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, errors: [], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should be able to import 2 rules with action connectors', 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 = { - ...getSimpleRule('rule-1'), - actions: [ - { - group: 'default', - id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', - action_type_id: hookAction.actionTypeId, - params: {}, - }, - ], - }; - - const rule2: ReturnType = { - ...getSimpleRule('rule-2'), - actions: [ - { - group: 'default', - id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', - action_type_id: '.index', - params: {}, + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', + action_type_id: '.webhook', + params: {}, + }, + ], + }), + getCustomQueryRuleParams({ + rule_id: 'rule-2', + actions: [ + { + group: 'default', + id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', + action_type_id: '.index', + params: {}, + }, + ], + }), + { + id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, }, - ], - }; - - const connector1 = { - id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', - type: 'action', - updated_at: '2023-01-25T14:35:52.852Z', - created_at: '2023-01-25T14:35:52.852Z', - version: 'WzUxNTksMV0=', - attributes: { - actionTypeId: '.webhook', - name: 'webhook', - isMissingSecrets: false, - config: {}, - secrets: {}, - }, - references: [], - migrationVersion: { action: '8.3.0' }, - coreMigrationVersion: '8.7.0', - }; - const connector2 = { - id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', - type: 'action', - updated_at: '2023-01-25T14:35:52.852Z', - created_at: '2023-01-25T14:35:52.852Z', - version: 'WzUxNTksMV0=', - attributes: { - actionTypeId: '.index', - name: 'index', - isMissingSecrets: false, - config: {}, - secrets: {}, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', }, - references: [], - migrationVersion: { action: '8.3.0' }, - coreMigrationVersion: '8.7.0', - }; - const rule1String = JSON.stringify(rule1); - const rule2String = JSON.stringify(rule2); - const connector12String = JSON.stringify(connector1); - const connector22String = JSON.stringify(connector2); - const buffer = Buffer.from( - `${rule1String}\n${rule2String}\n${connector12String}\n${connector22String}\n` + { + id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.index', + name: 'index', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + } ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', buffer, 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ success: true, success_count: 2, rules_count: 2, errors: [], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: true, action_connectors_success_count: 2, action_connectors_errors: [], @@ -1235,69 +1022,56 @@ export default ({ getService }: FtrProviderContext): void => { }); 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 = { - ...getSimpleRule('rule-1'), - actions: [ - { - group: 'default', - id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', - action_type_id: hookAction.actionTypeId, - params: {}, - }, - ], - }; - - const rule2: ReturnType = { - ...getSimpleRule('rule-2'), - actions: [ - { - group: 'default', - id: 'cabc78e0-9031-11ed-b076-53cc4d57aa22', // <-- This does not exist - action_type_id: '.index', - params: {}, + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', + action_type_id: '.webhook', + params: {}, + }, + ], + }), + getCustomQueryRuleParams({ + rule_id: 'rule-2', + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aa22', // <-- This does not exist + action_type_id: '.index', + params: {}, + }, + ], + }), + { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, }, - ], - }; - - const connector = { - id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', - type: 'action', - updated_at: '2023-01-25T14:35:52.852Z', - created_at: '2023-01-25T14:35:52.852Z', - version: 'WzUxNTksMV0=', - attributes: { - actionTypeId: '.webhook', - name: 'webhook', - isMissingSecrets: false, - config: {}, - secrets: {}, - }, - references: [], - migrationVersion: { action: '8.3.0' }, - coreMigrationVersion: '8.7.0', - }; - - const rule1String = JSON.stringify(rule1); - const rule2String = JSON.stringify(rule2); - const connector2String = JSON.stringify(connector); - - const buffer = Buffer.from(`${rule1String}\n${rule2String}\n${connector2String}\n`); + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + } + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach('file', buffer, 'rules.ndjson') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ success: false, success_count: 0, rules_count: 2, @@ -1312,9 +1086,6 @@ export default ({ getService }: FtrProviderContext): void => { }, }, ], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: false, action_connectors_success_count: 0, action_connectors_errors: [ @@ -1335,6 +1106,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('migrate pre-8.0 action connector ids', () => { const defaultSpaceActionConnectorId = '61b17790-544e-11ec-a349-11361cc441c4'; const space714ActionConnectorId = '51b17790-544e-11ec-a349-11361cc441c4'; + beforeEach(async () => { await esArchiver.load( 'x-pack/test/functional/es_archives/security_solution/import_rule_connector' @@ -1359,9 +1131,12 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body.success).to.eql(true); - expect(body.success_count).to.eql(1); - expect(body.errors.length).to.eql(0); + + expect(body).toMatchObject({ + success: true, + success_count: 1, + errors: [], + }); }); it('should import a non-default-space 7.16 rule with a connector made in the non-default space', async () => { @@ -1376,14 +1151,11 @@ export default ({ getService }: FtrProviderContext): void => { .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body).to.eql({ + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, errors: [], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: true, action_connectors_success_count: 1, action_connectors_warnings: [], @@ -1402,20 +1174,19 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body).to.eql({ + + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, errors: [], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: true, action_connectors_success_count: 1, action_connectors_warnings: [], action_connectors_errors: [], }); }); + it('importing a non-default-space 7.16 rule with a connector made in the non-default space into the default space should result in a 404 if the file does not contain connectors', async () => { // connectorId is from the 7.x connector here // x-pack/test/functional/es_archives/security_solution/import_rule_connector @@ -1427,11 +1198,18 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body.success).to.equal(false); - expect(body.errors[0].error.status_code).to.equal(404); - expect(body.errors[0].error.message).to.equal( - `1 connector is missing. Connector id missing is: ${space714ActionConnectorId}` - ); + + expect(body).toMatchObject({ + success: false, + errors: [ + expect.objectContaining({ + error: { + status_code: 404, + message: `1 connector is missing. Connector id missing is: ${space714ActionConnectorId}`, + }, + }), + ], + }); }); // When objects become share-capable we will either add / update this test it('importing a non-default-space 7.16 rule with a connector made in the non-default space into a different non-default space should result in a 404', async () => { @@ -1447,13 +1225,21 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body.success).to.equal(false); - expect(body.errors[0].error.status_code).to.equal(404); - expect(body.errors[0].error.message).to.equal( - `1 connector is missing. Connector id missing is: ${space714ActionConnectorId}` - ); + + expect(body).toMatchObject({ + success: false, + errors: [ + expect.objectContaining({ + error: { + status_code: 404, + message: `1 connector is missing. Connector id missing is: ${space714ActionConnectorId}`, + }, + }), + ], + }); }); }); + describe('should be imported into the default space', () => { it('should import a default-space 7.16 rule with a connector made in the default space into a non-default space successfully', async () => { await esArchiver.load( @@ -1473,14 +1259,12 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body).to.eql({ + + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, errors: [], - exceptions_errors: [], - exceptions_success: true, - exceptions_success_count: 0, action_connectors_success: true, action_connectors_success_count: 1, action_connectors_warnings: [], @@ -1501,10 +1285,14 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body.success).to.equal(true); - expect(body.success_count).to.eql(1); - expect(body.errors.length).to.eql(0); + + expect(body).toMatchObject({ + success: true, + success_count: 1, + errors: [], + }); }); + it('importing a default-space 7.16 rule with a connector made in the default space into a non-default space should result in a 404', async () => { await esArchiver.load( 'x-pack/test/functional/es_archives/security_solution/import_rule_connector' @@ -1521,11 +1309,18 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .attach('file', buffer, 'rules.ndjson') .expect(200); - expect(body.success).to.equal(false); - expect(body.errors[0].error.status_code).to.equal(404); - expect(body.errors[0].error.message).to.equal( - `1 connector is missing. Connector id missing is: ${defaultSpaceActionConnectorId}` - ); + + expect(body).toMatchObject({ + success: false, + errors: [ + expect.objectContaining({ + error: { + status_code: 404, + message: `1 connector is missing. Connector id missing is: ${defaultSpaceActionConnectorId}`, + }, + }), + ], + }); }); }); }); @@ -1543,26 +1338,21 @@ export default ({ getService }: FtrProviderContext): void => { thereby enabling simulation of migration scenarios. */ it('should be able to import a rule and an old version exception list, then delete it successfully', async () => { - const simpleRule = getSimpleRule('rule-1'); + const ndjson = combineToNdJson( + getCustomQueryRuleParams(), + getImportExceptionsListSchemaMock('test_list_id'), + getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id') + ); // import old exception version const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - Buffer.from( - toNdJsonString([ - simpleRule, - getImportExceptionsListSchemaMock('test_list_id'), - getImportExceptionsListItemSchemaMock('test_item_id', 'test_list_id'), - ]) - ), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, @@ -1570,10 +1360,6 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); // delete the exception list item by its item_id @@ -1585,25 +1371,20 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should be able to import a rule and an exception list', async () => { - const simpleRule = getSimpleRule('rule-1'); + const ndjson = combineToNdJson( + getCustomQueryRuleParams(), + getImportExceptionsListSchemaMock('test_list_id'), + getImportExceptionsListItemNewerVersionSchemaMock('test_item_id', 'test_list_id') + ); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - Buffer.from( - toNdJsonString([ - simpleRule, - getImportExceptionsListSchemaMock('test_list_id'), - getImportExceptionsListItemNewerVersionSchemaMock('test_item_id', 'test_list_id'), - ]) - ), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - expect(body).to.eql({ + + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, @@ -1611,10 +1392,6 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); @@ -1631,47 +1408,47 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - const simpleRule: ReturnType = { - ...getSimpleRule('rule-1'), + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + exceptions_list: [ + { + id: exceptionBody.id, + list_id: 'i_exist', + type: 'detection', + namespace_type: 'single', + }, + { + id: 'i_dont_exist', + list_id: '123', + type: 'detection', + namespace_type: 'single', + }, + ], + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(rule).toMatchObject({ exceptions_list: [ { id: exceptionBody.id, list_id: 'i_exist', - type: 'detection', namespace_type: 'single', - }, - { - id: 'i_dont_exist', - list_id: '123', type: 'detection', - namespace_type: 'single', }, ], - }; - const { body } = await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') - .expect(200); - - const { body: ruleResponse } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - - const bodyToCompare = removeServerGeneratedProperties(ruleResponse); - expect(bodyToCompare.exceptions_list).to.eql([ - { - id: exceptionBody.id, - list_id: 'i_exist', - namespace_type: 'single', - type: 'detection', - }, - ]); + }); - expect(body).to.eql({ + expect(body).toMatchObject({ success: false, success_count: 1, rules_count: 1, @@ -1688,27 +1465,58 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should resolve exception references when importing into a clean slate', async () => { // So importing a rule that references an exception list // Keep in mind, no exception lists or rules exist yet - const simpleRule: ReturnType = { - ...getSimpleRule('rule-1'), - exceptions_list: [ - { - id: 'abc', - list_id: 'i_exist', - type: 'detection', - namespace_type: 'single', - }, - ], - }; + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + exceptions_list: [ + { + id: 'abc', + list_id: 'i_exist', + type: 'detection', + namespace_type: 'single', + }, + ], + }), + { + ...getImportExceptionsListSchemaMock('i_exist'), + id: 'abc', + type: 'detection', + namespace_type: 'single', + }, + { + description: 'some description', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + item_id: 'item_id_1', + list_id: 'i_exist', + name: 'Query with a rule id', + type: 'simple', + } + ); // Importing the "simpleRule", along with the exception list // it's referencing and the list's item @@ -1716,57 +1524,11 @@ export default ({ getService }: FtrProviderContext): void => { .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - Buffer.from( - toNdJsonString([ - simpleRule, - { - ...getImportExceptionsListSchemaMock('i_exist'), - id: 'abc', - type: 'detection', - namespace_type: 'single', - }, - { - description: 'some description', - entries: [ - { - entries: [ - { - field: 'nested.field', - operator: 'included', - type: 'match', - value: 'some value', - }, - ], - field: 'some.parentField', - type: 'nested', - }, - { - field: 'some.not.nested.field', - operator: 'included', - type: 'match', - value: 'some value', - }, - ], - item_id: 'item_id_1', - list_id: 'i_exist', - name: 'Query with a rule id', - type: 'simple', - }, - ]) - ), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const { body: ruleResponse } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - const bodyToCompare = removeServerGeneratedProperties(ruleResponse); - const referencedExceptionList = ruleResponse.exceptions_list[0]; + const importedRule = await fetchRule(supertest, { ruleId: 'rule-1' }); + const referencedExceptionList = importedRule.exceptions_list[0]; // create an exception list const { body: exceptionBody } = await supertest @@ -1776,7 +1538,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(bodyToCompare.exceptions_list).to.eql([ + expect(importedRule.exceptions_list).toEqual([ { id: exceptionBody.id, list_id: 'i_exist', @@ -1785,7 +1547,7 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); - expect(body).to.eql({ + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, @@ -1793,27 +1555,69 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); it('should resolve exception references that include comments', async () => { // So importing a rule that references an exception list // Keep in mind, no exception lists or rules exist yet - const simpleRule: ReturnType = { - ...getSimpleRule('rule-1'), - exceptions_list: [ - { - id: 'abc', - list_id: 'i_exist', - type: 'detection', - namespace_type: 'single', - }, - ], - }; + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + exceptions_list: [ + { + id: 'abc', + list_id: 'i_exist', + type: 'detection', + namespace_type: 'single', + }, + ], + }), + { + ...getImportExceptionsListSchemaMock('i_exist'), + id: 'abc', + type: 'detection', + namespace_type: 'single', + }, + { + comments: [ + { + comment: 'This is an exception to the rule', + created_at: '2022-02-04T02:27:40.938Z', + created_by: 'elastic', + id: '845fc456-91ff-4530-bcc1-5b7ebd2f75b5', + }, + { + comment: 'I decided to add a new comment', + }, + ], + description: 'some description', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + item_id: 'item_id_1', + list_id: 'i_exist', + name: 'Query with a rule id', + type: 'simple', + } + ); // Importing the "simpleRule", along with the exception list // it's referencing and the list's item @@ -1821,68 +1625,11 @@ export default ({ getService }: FtrProviderContext): void => { .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - Buffer.from( - toNdJsonString([ - simpleRule, - { - ...getImportExceptionsListSchemaMock('i_exist'), - id: 'abc', - type: 'detection', - namespace_type: 'single', - }, - { - comments: [ - { - comment: 'This is an exception to the rule', - created_at: '2022-02-04T02:27:40.938Z', - created_by: 'elastic', - id: '845fc456-91ff-4530-bcc1-5b7ebd2f75b5', - }, - { - comment: 'I decided to add a new comment', - }, - ], - description: 'some description', - entries: [ - { - entries: [ - { - field: 'nested.field', - operator: 'included', - type: 'match', - value: 'some value', - }, - ], - field: 'some.parentField', - type: 'nested', - }, - { - field: 'some.not.nested.field', - operator: 'included', - type: 'match', - value: 'some value', - }, - ], - item_id: 'item_id_1', - list_id: 'i_exist', - name: 'Query with a rule id', - type: 'simple', - }, - ]) - ), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect(200); - const { body: ruleResponse } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) - .set('elastic-api-version', '2023-10-31') - .send() - .expect(200); - const bodyToCompare = removeServerGeneratedProperties(ruleResponse); - const referencedExceptionList = ruleResponse.exceptions_list[0]; + const importedRule = await fetchRule(supertest, { ruleId: 'rule-1' }); + const referencedExceptionList = importedRule.exceptions_list[0]; // create an exception list const { body: exceptionBody } = await supertest @@ -1892,7 +1639,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(bodyToCompare.exceptions_list).to.eql([ + expect(importedRule.exceptions_list).toEqual([ { id: exceptionBody.id, list_id: 'i_exist', @@ -1906,7 +1653,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(exceptionItemBody.comments).to.eql([ + expect(exceptionItemBody.comments).toEqual([ { comment: 'This is an exception to the rule', created_at: `${exceptionItemBody.comments[0].created_at}`, @@ -1921,7 +1668,7 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); - expect(body).to.eql({ + expect(body).toMatchObject({ success: true, success_count: 1, rules_count: 1, @@ -1929,25 +1676,21 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, - action_connectors_success: true, - action_connectors_success_count: 0, - action_connectors_errors: [], - action_connectors_warnings: [], }); }); }); it('should import a rule with "investigation_fields', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + investigation_fields: { field_names: ['foo'] }, + }) + ); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach( - 'file', - getRulesAsNdjson([ - { ...getSimpleRule(), investigation_fields: { field_names: ['foo'] } }, - ]), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); }); @@ -1955,104 +1698,117 @@ export default ({ getService }: FtrProviderContext): void => { describe('legacy investigation fields', () => { it('imports rule with investigation fields as array', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + ruleId: 'rule-1', + // mimicking what an 8.10 rule would look like + // we don't want to support this type in our APIs any longer, but do + // want to allow users to import rules from 8.10 + // @ts-expect-error + investigation_fields: ['foo', 'bar'], + }) + ); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - getSimpleRuleAsNdjsonWithLegacyInvestigationField(['rule-1'], false, { - // mimicking what an 8.10 rule would look like - // we don't want to support this type in our APIs any longer, but do - // want to allow users to import rules from 8.10 - investigation_fields: ['foo', 'bar'] as unknown as InvestigationFields, - }), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); - const rule = await getRule(supertest, log, 'rule-1'); - expect(rule.investigation_fields).to.eql({ field_names: ['foo', 'bar'] }); + const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(rule.investigation_fields).toEqual({ field_names: ['foo', 'bar'] }); + /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, rule.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql({ field_names: ['foo', 'bar'] }); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['foo', 'bar'] }, + es, + rule.id + ); + expect(isInvestigationFieldMigratedInSo).toBeTruthy(); }); it('imports rule with investigation fields as empty array', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + ruleId: 'rule-1', + // mimicking what an 8.10 rule would look like + // we don't want to support this type in our APIs any longer, but do + // want to allow users to import rules from 8.10 + // @ts-expect-error + investigation_fields: [], + }) + ); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - getSimpleRuleAsNdjsonWithLegacyInvestigationField(['rule-1'], false, { - // mimicking what an 8.10 rule would look like - // we don't want to support this type in our APIs any longer, but do - // want to allow users to import rules from 8.10 - investigation_fields: [] as unknown as InvestigationFields, - }), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); - const rule = await getRule(supertest, log, 'rule-1'); - expect(rule.investigation_fields).to.eql(undefined); + const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(rule.investigation_fields).toBeUndefined(); + /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, rule.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(undefined); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + undefined, + es, + rule.id + ); + expect(isInvestigationFieldMigratedInSo).toBeTruthy(); }); it('imports rule with investigation fields as intended object type', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + investigation_fields: { + field_names: ['foo'], + }, + }) + ); + await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') .set('elastic-api-version', '2023-10-31') - .attach( - 'file', - getSimpleRuleAsNdjsonWithLegacyInvestigationField(['rule-1'], false, { - investigation_fields: { - field_names: ['foo'], - }, - }), - 'rules.ndjson' - ) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); - const rule = await getRule(supertest, log, 'rule-1'); - expect(rule.investigation_fields).to.eql({ field_names: ['foo'] }); + const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(rule.investigation_fields).toEqual({ field_names: ['foo'] }); /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, rule.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql({ field_names: ['foo'] }); + const isInvestigationFieldIntendedTypeInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['foo'] }, + es, + rule.id + ); + expect(isInvestigationFieldIntendedTypeInSo).toBeTruthy(); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules_with_overwrite.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules_with_overwrite.ts new file mode 100644 index 0000000000000..39eee5f2dda0b --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules_with_overwrite.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; + +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createRule, + deleteAllRules, + combineToNdJson, + getCustomQueryRuleParams, + fetchRule, +} from '../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @serverless @skipInQA import_rules with rule overwrite set to "true"', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + }); + + it('DOES NOT report a conflict if there is an attempt to import two rules with the same rule_id', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ rule_id: 'rule-1', name: 'Rule 1' }), + getCustomQueryRuleParams({ rule_id: 'rule-1', name: 'Rule 2' }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 2, + }); + + const importedRule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(importedRule.name).toBe('Rule 2'); + }); + + it('DOES NOT report a conflict if there is an attempt to import a rule twice', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'imported-rule', + name: 'Imported rule', + }) + ); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + }); + }); + + it('overwrites an existing rule', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + name: 'Existing rule', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + name: 'Imported rule', + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + }); + + const importedRule = await fetchRule(supertest, { ruleId: 'existing-rule' }); + + expect(importedRule.name).toBe('Imported rule'); + }); + + /** + * Existing rule may have nullable fields set to a value (e.g. `timestamp_override` is set to `some.value`) but + * a rule to import doesn't have these fields set (e.g. `timestamp_override` is NOT present at all in the ndjson file). + * We expect the updated rule won't have such fields preserved (e.g. `timestamp_override` will be removed). + */ + it('ensures overwritten rule DOES NOT preserve fields missed in the imported rule', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + rule_name_override: 'some name', + timestamp_override: 'some.value', + timeline_id: 'some id', + timeline_title: 'some title', + outcome: 'exactMatch', + alias_target_id: 'some id', + license: 'some license', + note: 'some notes', + building_block_type: 'some type', + output_index: 'some-index', + namespace: 'some-namespace', + meta: { + some: 'field', + }, + investigation_fields: { field_names: ['a', 'b', 'c'] }, + throttle: 'no_actions', + }) + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'existing-rule', + namespace: 'abc', + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + }); + + const importedRule = await fetchRule(supertest, { ruleId: 'existing-rule' }); + + expect(importedRule).toMatchObject({ + rule_id: 'existing-rule', + output_index: '', + }); + expect(importedRule).toEqual( + expect.not.objectContaining({ + rule_name_override: expect.anything(), + timestamp_override: expect.anything(), + timeline_id: expect.anything(), + timeline_title: expect.anything(), + outcome: expect.anything(), + alias_target_id: expect.anything(), + license: expect.anything(), + note: expect.anything(), + building_block_type: expect.anything(), + namespace: expect.anything(), + meta: expect.anything(), + investigation_fields: expect.anything(), + throttle: expect.anything(), + }) + ); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/utils/create_connector.ts b/x-pack/test/detection_engine_api_integration/utils/create_connector.ts new file mode 100644 index 0000000000000..0ca8f88166c89 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/create_connector.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type SuperTest from 'supertest'; + +export interface CreateConnectorBody { + readonly name: string; + readonly config: Record; + readonly connector_type_id: string; + readonly secrets: Record; +} + +export async function createConnector( + supertest: SuperTest.SuperTest, + connector: CreateConnectorBody, + id = '' +): Promise { + const { body } = await supertest + .post(`/api/actions/connector/${id}`) + .set('kbn-xsrf', 'foo') + .send(connector) + .expect(200); + + return body.id; +} diff --git a/x-pack/test/detection_engine_api_integration/utils/fetch_rule.ts b/x-pack/test/detection_engine_api_integration/utils/fetch_rule.ts new file mode 100644 index 0000000000000..8e9e1008ff404 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/fetch_rule.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type SuperTest from 'supertest'; +import type { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine'; + +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; + +/** + * Helper to cut down on the noise in some of the tests. This gets + * a particular rule. + * + * @param supertest The supertest deps + * @param rule The rule to create + */ +export const fetchRule = async ( + supertest: SuperTest.SuperTest, + idOrRuleId: { id: string; ruleId?: never } | { id?: never; ruleId: string } +): Promise => + ( + await supertest + .get(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .query({ id: idOrRuleId.id, rule_id: idOrRuleId.ruleId }) + .expect(200) + ).body; diff --git a/x-pack/test/detection_engine_api_integration/utils/get_rules_as_ndjson.ts b/x-pack/test/detection_engine_api_integration/utils/get_rules_as_ndjson.ts deleted file mode 100644 index 24d448af21f1b..0000000000000 --- a/x-pack/test/detection_engine_api_integration/utils/get_rules_as_ndjson.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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. - */ - -/** - * Given an array of objects (assuming rules) this will return a ndjson buffer which is useful - * for testing uploads. - * @param rules Array of rules - */ -export const getRulesAsNdjson = (rules: unknown[]): Buffer => { - const stringOfRules = rules.map((rule) => { - return JSON.stringify(rule); - }); - return Buffer.from(stringOfRules.join('\n')); -}; diff --git a/x-pack/test/detection_engine_api_integration/utils/get_threshold_rule_for_alert_testing.ts b/x-pack/test/detection_engine_api_integration/utils/get_threshold_rule_for_alert_testing.ts new file mode 100644 index 0000000000000..a1409ef46af34 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/get_threshold_rule_for_alert_testing.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + QueryRuleCreateProps, + ThresholdRuleCreateProps, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; + +/** + * This is a typical signal testing rule that is easy for most basic testing of output of Threshold alerts. + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal + * creation for Threshold and testing by getting all the alerts at once. + * @param ruleId The optional ruleId which is threshold-rule by default. + * @param enabled Enables the rule on creation or not. Defaulted to true. + */ +export const getThresholdRuleForAlertTesting = ( + index: string[], + ruleId = 'threshold-rule', + enabled = true +): ThresholdRuleCreateProps => ({ + ...getRuleForAlertTesting(index, ruleId, enabled), + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'process.name', + value: 21, + }, + alert_suppression: undefined, +}); + +/** + * This is a typical signal testing rule that is easy for most basic testing of output of alerts. + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal + * creation and testing by getting all the alerts at once. + * @param ruleId The optional ruleId which is rule-1 by default. + * @param enabled Enables the rule on creation or not. Defaulted to true. + */ +const getRuleForAlertTesting = ( + index: string[], + ruleId = 'rule-1', + enabled = true +): QueryRuleCreateProps => ({ + name: 'Alert Testing Query', + description: 'Tests a simple query', + enabled, + risk_score: 1, + rule_id: ruleId, + severity: 'high', + index, + type: 'query', + query: '*:*', + from: '1900-01-01T00:00:00.000Z', +}); diff --git a/x-pack/test/detection_engine_api_integration/utils/get_web_hook_connector_params.ts b/x-pack/test/detection_engine_api_integration/utils/get_web_hook_connector_params.ts new file mode 100644 index 0000000000000..636e4c8ce5627 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/get_web_hook_connector_params.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CreateConnectorBody } from './create_connector'; + +export function getWebHookConnectorParams(): CreateConnectorBody { + return { + name: 'Webhook connector', + connector_type_id: '.webhook', + config: { + method: 'post', + url: 'http://localhost', + }, + secrets: { + user: 'example', + password: 'example', + }, + }; +} diff --git a/x-pack/test/detection_engine_api_integration/utils/index.ts b/x-pack/test/detection_engine_api_integration/utils/index.ts index 7a17f692540ce..0f2fa1d39cd69 100644 --- a/x-pack/test/detection_engine_api_integration/utils/index.ts +++ b/x-pack/test/detection_engine_api_integration/utils/index.ts @@ -39,7 +39,6 @@ export * from './get_query_all_signals'; export * from './get_query_signals_rule_id'; export * from './get_query_signal_ids'; export * from './get_rule'; -export * from './get_rules_as_ndjson'; export * from './get_rule_for_signal_testing'; export * from './get_rule_so_by_id'; export * from './get_rule_for_signal_testing_with_timestamp_override'; @@ -91,3 +90,7 @@ export * from './get_legacy_action_so'; export * from './delete_all_exceptions'; export * from './combine_to_ndjson'; export * from './get_custom_query_rule_params'; +export * from './fetch_rule'; +export * from './get_web_hook_connector_params'; +export * from './create_connector'; +export * from './get_threshold_rule_for_alert_testing'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts new file mode 100644 index 0000000000000..0b7f1acd0662f --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts @@ -0,0 +1,432 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; + +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID } from '../../../../../config/shared'; +import { + getCustomQueryRuleParams, + createRule, + createAlertsIndex, + deleteAllRules, + deleteAllAlerts, + waitForRulePartialFailure, +} from '../../../utils'; +import { binaryToString } from '../../../../lists_and_exception_lists/utils'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { getWebHookConnectorParams } from '../../../utils/connectors/get_web_hook_connector_params'; +import { createConnector } from '../../../utils/connectors'; + +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const log = getService('log'); + const es = getService('es'); + + describe('@ess @brokenInServerless @skipInQA export_rules', () => { + describe('exporting rules', () => { + beforeEach(async () => { + await createAlertsIndex(supertest, log); + }); + + afterEach(async () => { + await deleteAllAlerts(supertest, log, es); + await deleteAllRules(supertest, log); + }); + + it('should set the response content types to be expected', async () => { + await createRule(supertest, log, getCustomQueryRuleParams()); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .expect('Content-Type', 'application/ndjson') + .expect('Content-Disposition', 'attachment; filename="export.ndjson"'); + }); + + it('should validate exported rule schema when it is exported by its rule_id', async () => { + const ruleId = 'rule-1'; + + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: ruleId, + enabled: true, + }) + ); + + await waitForRulePartialFailure({ + supertest, + log, + ruleId, + }); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send({ + objects: [{ rule_id: 'rule-1' }], + }) + .expect(200) + .parse(binaryToString); + + const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); + + expectToMatchRuleSchema(exportedRule); + }); + + it('should validate all exported rules schema', async () => { + const ruleId1 = 'rule-1'; + const ruleId2 = 'rule-2'; + + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: ruleId1, + enabled: true, + }) + ); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: ruleId2, + enabled: true, + }) + ); + + await waitForRulePartialFailure({ + supertest, + log, + ruleId: ruleId1, + }); + await waitForRulePartialFailure({ + supertest, + log, + ruleId: ruleId2, + }); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .parse(binaryToString); + + const exportedRule1 = JSON.parse(body.toString().split(/\n/)[1]); + const exportedRule2 = JSON.parse(body.toString().split(/\n/)[0]); + + expectToMatchRuleSchema(exportedRule1); + expectToMatchRuleSchema(exportedRule2); + }); + + it('should export a exported count with a single rule_id', async () => { + await createRule(supertest, log, getCustomQueryRuleParams()); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .parse(binaryToString); + + const bodySplitAndParsed = JSON.parse(body.toString().split(/\n/)[1]); + + expect(bodySplitAndParsed).toEqual({ + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_count: 1, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + missing_rules: [], + missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], + }); + }); + + it('should export exactly two rules given two rules', async () => { + const ruleToExport1 = getCustomQueryRuleParams({ rule_id: 'rule-1' }); + const ruleToExport2 = getCustomQueryRuleParams({ rule_id: 'rule-2' }); + + await createRule(supertest, log, ruleToExport1); + await createRule(supertest, log, ruleToExport2); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .parse(binaryToString); + + const exportedRule1 = JSON.parse(body.toString().split(/\n/)[0]); + const exportedRule2 = JSON.parse(body.toString().split(/\n/)[1]); + + expect([exportedRule1, exportedRule2]).toEqual( + expect.arrayContaining([ + expect.objectContaining(ruleToExport1), + expect.objectContaining(ruleToExport2), + ]) + ); + }); + + it('should export multiple actions attached to 1 rule', async () => { + const webHookConnectorParams = getWebHookConnectorParams(); + const webHookConnectorId1 = await createConnector(supertest, webHookConnectorParams); + const webHookConnectorId2 = await createConnector(supertest, webHookConnectorParams); + + const action1 = { + group: 'default', + id: webHookConnectorId1, + action_type_id: webHookConnectorParams.connector_type_id, + params: {}, + }; + const action2 = { + group: 'default', + id: webHookConnectorId2, + action_type_id: webHookConnectorParams.connector_type_id, + params: {}, + }; + + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + actions: [action1, action2], + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .parse(binaryToString); + + const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); + + expect(exportedRule).toMatchObject({ + actions: [ + { + ...action1, + uuid: expect.any(String), + frequency: { summary: true, throttle: null, notifyWhen: 'onActiveAlert' }, + }, + { + ...action2, + uuid: expect.any(String), + frequency: { summary: true, throttle: null, notifyWhen: 'onActiveAlert' }, + }, + ], + }); + }); + + it('should export actions attached to 2 rules', async () => { + const webHookConnectorParams = getWebHookConnectorParams(); + const webHookConnectorId = await createConnector(supertest, webHookConnectorParams); + + const action = { + group: 'default', + id: webHookConnectorId, + action_type_id: webHookConnectorParams.connector_type_id, + params: {}, + }; + + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', actions: [action] }) + ); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', actions: [action] }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .parse(binaryToString); + + const exportedRule1 = JSON.parse(body.toString().split(/\n/)[0]); + const exportedRule2 = JSON.parse(body.toString().split(/\n/)[1]); + + expect(exportedRule1).toMatchObject({ + actions: [ + { + ...action, + uuid: expect.any(String), + frequency: { summary: true, throttle: null, notifyWhen: 'onActiveAlert' }, + }, + ], + }); + expect(exportedRule2).toMatchObject({ + actions: [ + { + ...action, + uuid: expect.any(String), + frequency: { summary: true, throttle: null, notifyWhen: 'onActiveAlert' }, + }, + ], + }); + }); + + it('should export action connectors with the rule', async () => { + const webHookConnectorParams = getWebHookConnectorParams(); + const webHookConnectorId = await createConnector(supertest, webHookConnectorParams); + + const action = { + group: 'default', + id: webHookConnectorId, + action_type_id: webHookConnectorParams.connector_type_id, + params: {}, + }; + + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + actions: [action], + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .parse(binaryToString); + + const exportedConnectors = JSON.parse(body.toString().split(/\n/)[1]); + const exportedSummary = JSON.parse(body.toString().split(/\n/)[2]); + + expect(exportedConnectors).toMatchObject({ + attributes: { + actionTypeId: '.webhook', + config: { + hasAuth: true, + headers: null, + method: 'post', + url: 'http://localhost', + }, + isMissingSecrets: true, + name: 'Webhook connector', + secrets: {}, + }, + references: [], + type: 'action', + }); + expect(exportedSummary).toMatchObject({ + exported_count: 2, + exported_rules_count: 1, + missing_rules: [], + missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 1, + missing_action_connection_count: 0, + missing_action_connections: [], + }); + }); + + it('should NOT export preconfigured actions connectors', async () => { + const action = { + group: 'default', + id: PRECONFIGURED_EMAIL_ACTION_CONNECTOR_ID, + action_type_id: '.email', + params: {}, + }; + + await createRule(supertest, log, getCustomQueryRuleParams({ actions: [action] })); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(200) + .parse(binaryToString); + + const exportedSummary = JSON.parse(body.toString().split(/\n/)[1]); + + expect(exportedSummary).toMatchObject({ + exported_count: 1, + exported_rules_count: 1, + missing_rules: [], + missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], + }); + }); + }); + }); +}; + +function expectToMatchRuleSchema(obj: RuleResponse): void { + expect(obj.throttle).toBeUndefined(); + expect(obj).toEqual({ + id: expect.any(String), + rule_id: expect.any(String), + enabled: expect.any(Boolean), + immutable: false, + updated_at: expect.any(String), + updated_by: expect.any(String), + created_at: expect.any(String), + created_by: expect.any(String), + name: expect.any(String), + tags: expect.arrayContaining([]), + interval: expect.any(String), + description: expect.any(String), + risk_score: expect.any(Number), + severity: expect.any(String), + output_index: expect.any(String), + author: expect.arrayContaining([]), + false_positives: expect.arrayContaining([]), + from: expect.any(String), + max_signals: expect.any(Number), + revision: expect.any(Number), + risk_score_mapping: expect.arrayContaining([]), + severity_mapping: expect.arrayContaining([]), + threat: expect.arrayContaining([]), + to: expect.any(String), + references: expect.arrayContaining([]), + version: expect.any(Number), + exceptions_list: expect.arrayContaining([]), + related_integrations: expect.arrayContaining([]), + required_fields: expect.arrayContaining([]), + setup: expect.any(String), + type: expect.any(String), + language: expect.any(String), + index: expect.arrayContaining([]), + query: expect.any(String), + actions: expect.arrayContaining([]), + }); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_rules_ess.ts new file mode 100644 index 0000000000000..d170fc328b8ff --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_rules_ess.ts @@ -0,0 +1,375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; + +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { ROLES } from '@kbn/security-solution-plugin/common/test'; +import { + createRule, + deleteAllRules, + createLegacyRuleAction, + getLegacyActionSO, + fetchRule, + checkInvestigationFieldSoValue, + combineToNdJson, + getCustomQueryRuleParams, +} from '../../../utils'; +import { + createUserAndRole, + deleteUserAndRole, +} from '../../../../../../common/services/security_solution'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { createConnector } from '../../../utils/connectors'; +import { getWebHookConnectorParams } from '../../../utils/connectors/get_web_hook_connector_params'; + +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const log = getService('log'); + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('@ess import_rules - ESS specific logic', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + }); + + it('should migrate legacy actions in existing rule if overwrite is set to true', async () => { + const ruleToOverwrite = getCustomQueryRuleParams({ + rule_id: 'rule-1', + interval: '1h', // action frequency can't be shorter than the schedule interval + }); + + const [connectorId, createdRule] = await Promise.all([ + createConnector(supertest, { + name: 'My action', + connector_type_id: '.slack', + config: {}, + secrets: { + webhookUrl: 'http://localhost:1234', + }, + }), + createRule(supertest, log, ruleToOverwrite), + ]); + await createLegacyRuleAction(supertest, createdRule.id, connectorId); + + // check for legacy sidecar action + const sidecarActionsResults = await getLegacyActionSO(es); + + expect(sidecarActionsResults.hits.hits.length).toBe(1); + expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).toBe(createdRule.id); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ rule_id: 'rule-1', name: 'some other name' }) + ); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + // legacy sidecar action should be gone + const sidecarActionsPostResults = await getLegacyActionSO(es); + + expect(sidecarActionsPostResults.hits.hits.length).toBe(0); + }); + + describe('importing rules with different roles', () => { + before(async () => { + await createUserAndRole(getService, ROLES.hunter_no_actions); + await createUserAndRole(getService, ROLES.hunter); + }); + after(async () => { + await deleteUserAndRole(getService, ROLES.hunter_no_actions); + await deleteUserAndRole(getService, ROLES.hunter); + }); + + it('should successfully import rules without actions when user has no actions privileges', async () => { + const ndjson = combineToNdJson(getCustomQueryRuleParams()); + + const { body } = await supertestWithoutAuth + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .auth(ROLES.hunter_no_actions, 'changeme') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], + }); + }); + + it('should NOT import rules with actions when user has "read" actions privileges', async () => { + const connectorId = await createConnector(supertest, getWebHookConnectorParams()); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-with-actions', + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: connectorId, + params: {}, + }, + ], + }), + { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + } + ); + + const { body } = await supertestWithoutAuth + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .auth(ROLES.hunter, 'changeme') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + success: false, + success_count: 0, + rules_count: 1, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + action_connectors_warnings: [], + }); + }); + + it('should NOT import rules with actions when a user has no actions privileges', async () => { + const connectorId = await createConnector(supertest, getWebHookConnectorParams()); + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-with-actions', + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: connectorId, + params: {}, + }, + ], + }), + { + id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + } + ); + + const { body } = await supertestWithoutAuth + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .auth(ROLES.hunter_no_actions, 'changeme') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + expect(body).toMatchObject({ + success: false, + success_count: 0, + errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unauthorized to get actions', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + rules_count: 1, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unauthorized to get actions', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + action_connectors_warnings: [], + }); + }); + }); + + describe('legacy investigation fields', () => { + it('imports rule with investigation fields as array', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + ruleId: 'rule-1', + // mimicking what an 8.10 rule would look like + // we don't want to support this type in our APIs any longer, but do + // want to allow users to import rules from 8.10 + // @ts-expect-error + investigation_fields: ['foo', 'bar'], + }) + ); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + + const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(rule.investigation_fields).toEqual({ field_names: ['foo', 'bar'] }); + + /** + * Confirm type on SO so that it's clear in the tests whether it's expected that + * the SO itself is migrated to the inteded object type, or if the transformation is + * happening just on the response. In this case, change should + * include a migration on SO. + */ + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['foo', 'bar'] }, + es, + rule.id + ); + expect(isInvestigationFieldMigratedInSo).toBeTruthy(); + }); + + it('imports rule with investigation fields as empty array', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + ruleId: 'rule-1', + // mimicking what an 8.10 rule would look like + // we don't want to support this type in our APIs any longer, but do + // want to allow users to import rules from 8.10 + // @ts-expect-error + investigation_fields: [], + }) + ); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + + const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(rule.investigation_fields).toBeUndefined(); + + /** + * Confirm type on SO so that it's clear in the tests whether it's expected that + * the SO itself is migrated to the inteded object type, or if the transformation is + * happening just on the response. In this case, change should + * include a migration on SO. + */ + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + undefined, + es, + rule.id + ); + expect(isInvestigationFieldMigratedInSo).toBeTruthy(); + }); + + it('imports rule with investigation fields as intended object type', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + investigation_fields: { + field_names: ['foo'], + }, + }) + ); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + + const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); + + expect(rule.investigation_fields).toEqual({ field_names: ['foo'] }); + /** + * Confirm type on SO so that it's clear in the tests whether it's expected that + * the SO itself is migrated to the inteded object type, or if the transformation is + * happening just on the response. In this case, change should + * include a migration on SO. + */ + const isInvestigationFieldIntendedTypeInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['foo'] }, + es, + rule.id + ); + expect(isInvestigationFieldIntendedTypeInSo).toBeTruthy(); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/index.ts new file mode 100644 index 0000000000000..7f9effb5388a8 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Rules Management - Rule import export API', function () { + loadTestFile(require.resolve('./export_rules')); + loadTestFile(require.resolve('./import_rules_ess')); + }); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/create_connector.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/create_connector.ts index 9c3f54e019653..0ca8f88166c89 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/create_connector.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/create_connector.ts @@ -7,7 +7,7 @@ import type SuperTest from 'supertest'; -interface CreateConnectorBody { +export interface CreateConnectorBody { readonly name: string; readonly config: Record; readonly connector_type_id: string; @@ -18,10 +18,12 @@ export async function createConnector( supertest: SuperTest.SuperTest, connector: CreateConnectorBody, id = '' -): Promise { - await supertest +): Promise { + const { body } = await supertest .post(`/api/actions/connector/${id}`) .set('kbn-xsrf', 'foo') .send(connector) .expect(200); + + return body.id; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_web_hook_connector_params.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_web_hook_connector_params.ts new file mode 100644 index 0000000000000..636e4c8ce5627 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_web_hook_connector_params.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CreateConnectorBody } from './create_connector'; + +export function getWebHookConnectorParams(): CreateConnectorBody { + return { + name: 'Webhook connector', + connector_type_id: '.webhook', + config: { + method: 'post', + url: 'http://localhost', + }, + secrets: { + user: 'example', + password: 'example', + }, + }; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/check_investigation_field_in_so.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/check_investigation_field_in_so.ts new file mode 100644 index 0000000000000..36804d6c0f50f --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/check_investigation_field_in_so.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Client } from '@elastic/elasticsearch'; +import { SavedObjectReference } from '@kbn/core/server'; +import { InvestigationFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { Rule } from '@kbn/alerting-plugin/common'; +import { BaseRuleParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_schema'; +import { isEqual } from 'lodash/fp'; +import { getRuleSOById } from './get_rule_so_by_id'; + +interface RuleSO { + alert: Rule; + references: SavedObjectReference[]; +} + +export const checkInvestigationFieldSoValue = async ( + ruleSO: RuleSO | undefined, + expectedSoValue: undefined | InvestigationFields, + es?: Client, + ruleId?: string +): Promise => { + if (!ruleSO && es && ruleId) { + const { + hits: { + hits: [{ _source: rule }], + }, + } = await getRuleSOById(es, ruleId); + + return isEqual(rule?.alert.params.investigationFields, expectedSoValue); + } + + return isEqual(ruleSO?.alert.params.investigationFields, expectedSoValue); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts index 47a76a1583596..d5457de81033c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts @@ -43,5 +43,7 @@ export * from './get_simple_ml_rule'; export * from './remove_server_generated_properties_including_rule_id'; export * from './get_simple_rule_output_without_rule_id'; export * from './get_simple_rule_without_rule_id'; - +export * from './rule_to_update_schema'; +export * from './update_rule'; export * from './prebuilt_rules'; +export * from './check_investigation_field_in_so';