From 934a06ccf7c599685e04469ff0801e461ee9c2d5 Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Tue, 13 Feb 2024 16:30:25 +0100 Subject: [PATCH] [Security Solution] Fix importing rules referencing preconfigured connectors (#176284) **Fixes:** https://github.com/elastic/kibana/issues/157253 ## Summary This PR fixes rules import with `overwrite_action_connectors` set to true when ndjson contains rules with actions referencing preconfigured action connectors. ## Details A user can preconfigure action connectors as described [here](https://www.elastic.co/guide/en/kibana/current/pre-configured-connectors.html). At the same time Elastic Could instances have Elastic-cloud-SMTP connector preconfigured. In particular import doesn't work as expected in Elastic Cloud for rules having actions referencing the preconfigured Elastic-cloud-SMTP connector. This is fixed by filtering out preconfigured connector ids so importing logic only handles custom action connectors. On top of this functional tests have been added to make sure the problem won't come back. ### 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](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5139) in Flaky test runner for ESS and Serverless and no flakiness has been revealed --- .../import_rules/rule_to_import.mock.ts | 95 ++-- .../import_rules/rule_to_import.test.ts | 268 ++++----- .../rule_to_import_validation.test.ts | 20 +- .../import_rule_action_connectors.test.ts | 224 +++++--- .../import_rule_action_connectors.ts | 70 ++- .../check_rule_exception_references.test.ts | 22 +- .../gather_referenced_exceptions.test.ts | 70 +-- .../logic/import/import_rules_utils.test.ts | 50 +- .../import_connectors.ts | 516 ++++++++++++++++++ .../trial_license_complete_tier/index.ts | 1 + .../utils/combine_to_ndjson.ts | 10 + .../utils/connectors/create_connector.ts | 27 + .../utils/connectors/delete_connector.ts | 15 + .../utils/connectors/get_connector.ts | 21 + .../utils/connectors/index.ts | 10 + .../detections_response/utils/index.ts | 1 + 16 files changed, 1020 insertions(+), 400 deletions(-) create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_connectors.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/combine_to_ndjson.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/create_connector.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/delete_connector.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_connector.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/index.ts diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.mock.ts index 6161e2a00f960..2b36645363edc 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.mock.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.mock.ts @@ -7,17 +7,19 @@ import type { RuleToImport } from './rule_to_import'; -export const getImportRulesSchemaMock = (ruleId = 'rule-1'): RuleToImport => ({ - description: 'some description', - name: 'Query with a rule id', - query: 'user.name: root or user.name: admin', - severity: 'high', - type: 'query', - risk_score: 55, - language: 'kuery', - rule_id: ruleId, - immutable: false, -}); +export const getImportRulesSchemaMock = (rewrites?: Partial): RuleToImport => + ({ + description: 'some description', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'query', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + immutable: false, + ...rewrites, + } as RuleToImport); export const getImportRulesWithIdSchemaMock = (ruleId = 'rule-1'): RuleToImport => ({ id: '6afb8ce1-ea94-4790-8653-fd0b021d2113', @@ -47,42 +49,46 @@ export const rulesToNdJsonString = (rules: RuleToImport[]) => { * @param ruleIds Array of ruleIds with which to generate rule JSON */ export const ruleIdsToNdJsonString = (ruleIds: string[]) => { - const rules = ruleIds.map((ruleId) => getImportRulesSchemaMock(ruleId)); + const rules = ruleIds.map((ruleId) => getImportRulesSchemaMock({ rule_id: ruleId })); return rulesToNdJsonString(rules); }; -export const getImportThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): RuleToImport => ({ - description: 'some description', - name: 'Query with a rule id', - query: 'user.name: root or user.name: admin', - severity: 'high', - type: 'threat_match', - risk_score: 55, - language: 'kuery', - rule_id: ruleId, - threat_index: ['index-123'], - threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }], - threat_query: '*:*', - threat_filters: [ - { - bool: { - must: [ - { - query_string: { - query: 'host.name: linux', - analyze_wildcard: true, - time_zone: 'Zulu', +export const getImportThreatMatchRulesSchemaMock = ( + rewrites?: Partial +): RuleToImport => + ({ + description: 'some description', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + threat_index: ['index-123'], + threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }], + threat_query: '*:*', + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, }, - }, - ], - filter: [], - should: [], - must_not: [], + ], + filter: [], + should: [], + must_not: [], + }, }, - }, - ], - immutable: false, -}); + ], + immutable: false, + ...rewrites, + } as RuleToImport); export const webHookConnector = { id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', @@ -104,8 +110,7 @@ export const webHookConnector = { export const ruleWithConnectorNdJSON = (): string => { const items = [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ actions: [ { group: 'default', @@ -114,7 +119,7 @@ export const ruleWithConnectorNdJSON = (): string => { params: {}, }, ], - }, + }), webHookConnector, ]; const stringOfExceptions = items.map((item) => JSON.stringify(item)); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.test.ts index 3f364c6619db6..2894da32593fa 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.test.ts @@ -27,10 +27,10 @@ describe('RuleToImport', () => { }); test('extra properties are removed', () => { - const payload: RuleToImportInput & { madeUp: string } = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ + // @ts-expect-error add an unknown field madeUp: 'hi', - }; + }); const result = RuleToImport.safeParse(payload); expectParseSuccess(result); @@ -241,10 +241,7 @@ describe('RuleToImport', () => { }); test('You can send in an empty array to threat', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - threat: [], - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ threat: [] }); const result = RuleToImport.safeParse(payload); @@ -289,10 +286,7 @@ describe('RuleToImport', () => { }); test('allows references to be sent as valid', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - references: ['index-1'], - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ references: ['index-1'] }); const result = RuleToImport.safeParse(payload); @@ -307,10 +301,10 @@ describe('RuleToImport', () => { }); test('references cannot be numbers', () => { - const payload: Omit & { references: number[] } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign wrong type value references: [5], - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -321,10 +315,10 @@ describe('RuleToImport', () => { }); test('indexes cannot be numbers', () => { - const payload: Omit & { index: number[] } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign wrong type value index: [5], - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -358,10 +352,9 @@ describe('RuleToImport', () => { }); test('saved_query type can have filters with it', () => { - const payload = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ filters: [], - }; + }); const result = RuleToImport.safeParse(payload); @@ -369,10 +362,10 @@ describe('RuleToImport', () => { }); test('filters cannot be a string', () => { - const payload: Omit & { filters: string } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign wrong type value filters: 'some string', - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -383,10 +376,7 @@ describe('RuleToImport', () => { }); test('language validates with kuery', () => { - const payload = { - ...getImportRulesSchemaMock(), - language: 'kuery', - }; + const payload = getImportRulesSchemaMock({ language: 'kuery' }); const result = RuleToImport.safeParse(payload); @@ -394,10 +384,7 @@ describe('RuleToImport', () => { }); test('language validates with lucene', () => { - const payload = { - ...getImportRulesSchemaMock(), - language: 'lucene', - }; + const payload = getImportRulesSchemaMock({ language: 'lucene' }); const result = RuleToImport.safeParse(payload); @@ -405,10 +392,10 @@ describe('RuleToImport', () => { }); test('language does not validate with something made up', () => { - const payload: Omit & { language: string } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value language: 'something-made-up', - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -419,10 +406,7 @@ describe('RuleToImport', () => { }); test('max_signals cannot be negative', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - max_signals: -1, - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ max_signals: -1 }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -433,10 +417,7 @@ describe('RuleToImport', () => { }); test('max_signals cannot be zero', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - max_signals: 0, - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ max_signals: 0 }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -447,10 +428,7 @@ describe('RuleToImport', () => { }); test('max_signals can be 1', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - max_signals: 1, - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ max_signals: 1 }); const result = RuleToImport.safeParse(payload); @@ -458,10 +436,7 @@ describe('RuleToImport', () => { }); test('You can optionally send in an array of tags', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - tags: ['tag_1', 'tag_2'], - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ tags: ['tag_1', 'tag_2'] }); const result = RuleToImport.safeParse(payload); @@ -469,10 +444,10 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of tags that are numbers', () => { - const payload: Omit & { tags: number[] } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value tags: [0, 1, 2], - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -483,11 +458,9 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of threat that are missing "framework"', () => { - const payload: Omit & { - threat: Array>>; - } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ threat: [ + // @ts-expect-error assign unsupported value { tactic: { id: 'fakeId', @@ -503,7 +476,7 @@ describe('RuleToImport', () => { ], }, ], - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -512,11 +485,9 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of threat that are missing "tactic"', () => { - const payload: Omit & { - threat: Array>>; - } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ threat: [ + // @ts-expect-error assign unsupported value { framework: 'fake', technique: [ @@ -528,7 +499,7 @@ describe('RuleToImport', () => { ], }, ], - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -537,10 +508,7 @@ describe('RuleToImport', () => { }); test('You can send in an array of threat that are missing "technique"', () => { - const payload: Omit & { - threat: Array>>; - } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ threat: [ { framework: 'fake', @@ -551,7 +519,7 @@ describe('RuleToImport', () => { }, }, ], - }; + }); const result = RuleToImport.safeParse(payload); @@ -559,10 +527,9 @@ describe('RuleToImport', () => { }); test('You can optionally send in an array of false positives', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ false_positives: ['false_1', 'false_2'], - }; + }); const result = RuleToImport.safeParse(payload); @@ -570,10 +537,10 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of false positives that are numbers', () => { - const payload: Omit & { false_positives: number[] } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value false_positives: [5, 4], - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -584,10 +551,10 @@ describe('RuleToImport', () => { }); test('You cannot set the immutable to a number when trying to create a rule', () => { - const payload: Omit & { immutable: number } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value immutable: 5, - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -598,10 +565,9 @@ describe('RuleToImport', () => { }); test('You can optionally set the immutable to be false', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ immutable: false, - }; + }); const result = RuleToImport.safeParse(payload); @@ -609,10 +575,10 @@ describe('RuleToImport', () => { }); test('You cannot set the immutable to be true', () => { - const payload: Omit & { immutable: true } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value immutable: true, - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -623,10 +589,10 @@ describe('RuleToImport', () => { }); test('You cannot set the immutable to be a number', () => { - const payload: Omit & { immutable: number } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value immutable: 5, - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -637,10 +603,9 @@ describe('RuleToImport', () => { }); test('You cannot set the risk_score to 101', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ risk_score: 101, - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -651,10 +616,9 @@ describe('RuleToImport', () => { }); test('You cannot set the risk_score to -1', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ risk_score: -1, - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -665,10 +629,9 @@ describe('RuleToImport', () => { }); test('You can set the risk_score to 0', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ risk_score: 0, - }; + }); const result = RuleToImport.safeParse(payload); @@ -676,10 +639,9 @@ describe('RuleToImport', () => { }); test('You can set the risk_score to 100', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ risk_score: 100, - }; + }); const result = RuleToImport.safeParse(payload); @@ -687,12 +649,11 @@ describe('RuleToImport', () => { }); test('You can set meta to any object you want', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ meta: { somethingMadeUp: { somethingElse: true }, }, - }; + }); const result = RuleToImport.safeParse(payload); @@ -700,10 +661,10 @@ describe('RuleToImport', () => { }); test('You cannot create meta as a string', () => { - const payload: Omit & { meta: string } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value meta: 'should not work', - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -714,11 +675,10 @@ describe('RuleToImport', () => { }); test('validates with timeline_id and timeline_title', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ timeline_id: 'timeline-id', timeline_title: 'timeline-title', - }; + }); const result = RuleToImport.safeParse(payload); @@ -726,10 +686,9 @@ describe('RuleToImport', () => { }); test('rule_id is required and you cannot get by with just id', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ id: 'c4e80a0d-e20f-4efc-84c1-08112da5a612', - }; + }); // @ts-expect-error delete payload.rule_id; @@ -740,13 +699,12 @@ describe('RuleToImport', () => { }); test('it validates with created_at, updated_at, created_by, updated_by values', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), + const payload: RuleToImportInput = getImportRulesSchemaMock({ created_at: '2020-01-09T06:15:24.749Z', updated_at: '2020-01-09T06:15:24.749Z', created_by: 'Braden Hassanabad', updated_by: 'Evan Hassanabad', - }; + }); const result = RuleToImport.safeParse(payload); @@ -754,10 +712,7 @@ describe('RuleToImport', () => { }); test('it does not validate with epoch strings for created_at', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - created_at: '1578550728650', - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ created_at: '1578550728650' }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -766,10 +721,7 @@ describe('RuleToImport', () => { }); test('it does not validate with epoch strings for updated_at', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - updated_at: '1578550728650', - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ updated_at: '1578550728650' }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -800,10 +752,10 @@ describe('RuleToImport', () => { }); test('You cannot set the severity to a value other than low, medium, high, or critical', () => { - const payload: Omit & { severity: string } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value severity: 'junk', - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -825,10 +777,12 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of actions that are missing "group"', () => { - const payload: Omit = { - ...getImportRulesSchemaMock(), - actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], - }; + const payload = getImportRulesSchemaMock({ + actions: [ + // @ts-expect-error assign unsupported value + { id: 'id', action_type_id: 'action_type_id', params: {} }, + ], + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -837,10 +791,12 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of actions that are missing "id"', () => { - const payload: Omit = { - ...getImportRulesSchemaMock(), - actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], - }; + const payload = getImportRulesSchemaMock({ + actions: [ + // @ts-expect-error assign unsupported value + { group: 'group', action_type_id: 'action_type_id', params: {} }, + ], + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -849,10 +805,12 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of actions that are missing "action_type_id"', () => { - const payload: Omit = { - ...getImportRulesSchemaMock(), - actions: [{ group: 'group', id: 'id', params: {} }], - }; + const payload = getImportRulesSchemaMock({ + actions: [ + // @ts-expect-error assign unsupported value + { group: 'group', id: 'id', params: {} }, + ], + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -863,10 +821,12 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of actions that are missing "params"', () => { - const payload: Omit = { - ...getImportRulesSchemaMock(), - actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], - }; + const payload = getImportRulesSchemaMock({ + actions: [ + // @ts-expect-error assign unsupported value + { group: 'group', id: 'id', action_type_id: 'action_type_id' }, + ], + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -875,17 +835,17 @@ describe('RuleToImport', () => { }); test('You cannot send in an array of actions that are including "actionTypeId"', () => { - const payload: Omit = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ actions: [ { group: 'group', id: 'id', + // @ts-expect-error assign unsupported value actionTypeId: 'actionTypeId', params: {}, }, ], - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -907,32 +867,28 @@ describe('RuleToImport', () => { describe('note', () => { test('You can set note to a string', () => { - const payload: RuleToImport = { - ...getImportRulesSchemaMock(), + const payload: RuleToImport = getImportRulesSchemaMock({ note: '# documentation markdown here', - }; + }); const result = RuleToImport.safeParse(payload); expectParseSuccess(result); }); test('You can set note to an empty string', () => { - const payload: RuleToImportInput = { - ...getImportRulesSchemaMock(), - note: '', - }; + const payload: RuleToImportInput = getImportRulesSchemaMock({ note: '' }); const result = RuleToImport.safeParse(payload); expectParseSuccess(result); }); test('You cannot create note as an object', () => { - const payload: Omit & { note: {} } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value note: { somethingHere: 'something else', }, - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); @@ -1102,10 +1058,10 @@ describe('RuleToImport', () => { }); test('data_view_id cannot be a number', () => { - const payload: Omit & { data_view_id: number } = { - ...getImportRulesSchemaMock(), + const payload = getImportRulesSchemaMock({ + // @ts-expect-error assign unsupported value data_view_id: 5, - }; + }); const result = RuleToImport.safeParse(payload); expectParseError(result); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import_validation.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import_validation.test.ts index 31ac993eb4053..597dcf0cc3bcb 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import_validation.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import_validation.test.ts @@ -12,40 +12,34 @@ import { validateRuleToImport } from './rule_to_import_validation'; describe('Rule to import schema, additional validation', () => { describe('validateRuleToImport', () => { test('You cannot omit timeline_title when timeline_id is present', () => { - const schema: RuleToImport = { - ...getImportRulesSchemaMock(), + const schema: RuleToImport = getImportRulesSchemaMock({ timeline_id: '123', - }; + }); delete schema.timeline_title; const errors = validateRuleToImport(schema); expect(errors).toEqual(['when "timeline_id" exists, "timeline_title" must also exist']); }); test('You cannot have empty string for timeline_title when timeline_id is present', () => { - const schema: RuleToImport = { - ...getImportRulesSchemaMock(), + const schema: RuleToImport = getImportRulesSchemaMock({ timeline_id: '123', timeline_title: '', - }; + }); const errors = validateRuleToImport(schema); expect(errors).toEqual(['"timeline_title" cannot be an empty string']); }); test('You cannot have timeline_title with an empty timeline_id', () => { - const schema: RuleToImport = { - ...getImportRulesSchemaMock(), + const schema: RuleToImport = getImportRulesSchemaMock({ timeline_id: '', timeline_title: 'some-title', - }; + }); const errors = validateRuleToImport(schema); expect(errors).toEqual(['"timeline_id" cannot be an empty string']); }); test('You cannot have timeline_title without timeline_id', () => { - const schema: RuleToImport = { - ...getImportRulesSchemaMock(), - timeline_title: 'some-title', - }; + const schema: RuleToImport = getImportRulesSchemaMock({ timeline_title: 'some-title' }); delete schema.timeline_id; const errors = validateRuleToImport(schema); expect(errors).toEqual(['when "timeline_title" exists, "timeline_id" must also exist']); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts index 8afecd245e2a9..84352c1ea0f1e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts @@ -13,8 +13,7 @@ import { importRuleActionConnectors } from './import_rule_action_connectors'; import { coreMock } from '@kbn/core/server/mocks'; const rules = [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ actions: [ { group: 'default', @@ -23,14 +22,9 @@ const rules = [ params: {}, }, ], - }, -]; -const rulesWithoutActions = [ - { - ...getImportRulesSchemaMock(), - actions: [], - }, + }), ]; +const rulesWithoutActions = [getImportRulesSchemaMock({ actions: [] })]; const actionConnectors = [webHookConnector]; const actionsClient = actionsClientMock.create(); actionsClient.getAll.mockResolvedValue([]); @@ -115,8 +109,7 @@ describe('importRuleActionConnectors', () => { const actionsImporter = core.savedObjects.getImporter; const ruleWith2Connectors = [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ actions: [ { group: 'default', @@ -135,7 +128,7 @@ describe('importRuleActionConnectors', () => { action_type_id: '.slack', }, ], - }, + }), ]; const res = await importRuleActionConnectors({ actionConnectors, @@ -189,8 +182,7 @@ describe('importRuleActionConnectors', () => { actionsClient, actionsImporter: actionsImporter(), rules: [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ actions: [ { group: 'default', @@ -205,7 +197,7 @@ describe('importRuleActionConnectors', () => { params: {}, }, ], - }, + }), ], overwrite: false, }); @@ -235,8 +227,8 @@ describe('importRuleActionConnectors', () => { actionsClient, actionsImporter: actionsImporter(), rules: [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ + rule_id: 'rule-1', actions: [ { group: 'default', @@ -245,9 +237,9 @@ describe('importRuleActionConnectors', () => { params: {}, }, ], - }, - { - ...getImportRulesSchemaMock('rule-2'), + }), + getImportRulesSchemaMock({ + rule_id: 'rule-2', actions: [ { group: 'default', @@ -256,7 +248,7 @@ describe('importRuleActionConnectors', () => { params: {}, }, ], - }, + }), ], overwrite: false, }); @@ -340,45 +332,6 @@ describe('importRuleActionConnectors', () => { expect(actionsImporter2Importer.import).not.toBeCalled(); }); - it('should not skip importing the action-connectors if all connectors have been imported/created before when overwrite is true', async () => { - core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ - import: jest.fn().mockResolvedValue({ - success: true, - successCount: 1, - errors: [], - warnings: [], - }), - }); - const actionsImporter = core.savedObjects.getImporter; - - actionsClient.getAll.mockResolvedValue([ - { - actionTypeId: '.webhook', - name: 'webhook', - isPreconfigured: true, - id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', - referencedByCount: 1, - isDeprecated: false, - isSystemAction: false, - }, - ]); - - const res = await importRuleActionConnectors({ - actionConnectors, - actionsClient, - actionsImporter: actionsImporter(), - rules, - overwrite: true, - }); - - expect(res).toEqual({ - success: true, - successCount: 1, - errors: [], - warnings: [], - }); - }); - it('should import one rule with connector successfully even if it was exported from different namespaces by generating destinationId and replace the old actionId with it', async () => { const successResults = [ { @@ -441,8 +394,8 @@ describe('importRuleActionConnectors', () => { it('should import multiple rules with connectors successfully even if they were exported from different namespaces by generating destinationIds and replace the old actionIds with them', async () => { const multipleRules = [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ + rule_id: 'rule_1', actions: [ { group: 'default', @@ -451,9 +404,8 @@ describe('importRuleActionConnectors', () => { params: {}, }, ], - }, - { - ...getImportRulesSchemaMock(), + }), + getImportRulesSchemaMock({ rule_id: 'rule_2', id: '0abc78e0-7031-11ed-b076-53cc4d57aaf1', actions: [ @@ -464,7 +416,7 @@ describe('importRuleActionConnectors', () => { params: {}, }, ], - }, + }), ]; const successResults = [ { @@ -535,7 +487,7 @@ describe('importRuleActionConnectors', () => { name: 'Query with a rule id', query: 'user.name: root or user.name: admin', risk_score: 55, - rule_id: 'rule-1', + rule_id: 'rule_1', severity: 'high', type: 'query', }, @@ -569,4 +521,142 @@ describe('importRuleActionConnectors', () => { rulesWithMigratedActions, }); }); + + describe('overwrite is set to "true"', () => { + it('should return an error when action connectors are missing in ndjson import file', async () => { + const rulesToImport = [ + getImportRulesSchemaMock({ + rule_id: 'rule-with-missed-action-connector', + actions: [ + { + group: 'default', + id: 'some-connector-id', + params: {}, + action_type_id: '.webhook', + }, + ], + }), + ]; + + actionsClient.getAll.mockResolvedValue([]); + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: core.savedObjects.getImporter(), + rules: rulesToImport, + overwrite: true, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: '1 connector is missing. Connector id missing is: some-connector-id', + status_code: 404, + }, + id: 'some-connector-id', + rule_id: 'rule-with-missed-action-connector', + }, + ], + warnings: [], + }); + }); + + it('should NOT return an error when a missing action connector in ndjson import file is a preconfigured one', async () => { + const rulesToImport = [ + getImportRulesSchemaMock({ + rule_id: 'rule-with-missed-action-connector', + actions: [ + { + group: 'default', + id: 'prebuilt-connector-id', + params: {}, + action_type_id: '.webhook', + }, + ], + }), + ]; + + actionsClient.getAll.mockResolvedValue([ + { + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: true, + id: 'prebuilt-connector-id', + referencedByCount: 1, + isDeprecated: false, + isSystemAction: false, + }, + ]); + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: core.savedObjects.getImporter(), + rules: rulesToImport, + overwrite: true, + }); + + expect(res).toEqual({ + success: true, + successCount: 0, + errors: [], + warnings: [], + }); + }); + + it('should not skip importing the action-connectors if all connectors have been imported/created before', async () => { + const rulesToImport = [ + getImportRulesSchemaMock({ + actions: [ + { + group: 'default', + id: 'connector-id', + action_type_id: '.webhook', + params: {}, + }, + ], + }), + ]; + + core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + errors: [], + warnings: [], + }), + }); + + actionsClient.getAll.mockResolvedValue([ + { + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: true, + id: 'connector-id', + referencedByCount: 1, + isDeprecated: false, + isSystemAction: false, + }, + ]); + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: core.savedObjects.getImporter(), + rules: rulesToImport, + overwrite: true, + }); + + expect(res).toEqual({ + success: true, + successCount: 0, + errors: [], + warnings: [], + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts index dfbacdcfd8ce2..db86ad158289c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts @@ -8,6 +8,8 @@ import { Readable } from 'stream'; import type { SavedObjectsImportResponse } from '@kbn/core-saved-objects-common'; import type { SavedObject } from '@kbn/core-saved-objects-server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types'; import type { RuleToImport } from '../../../../../../../common/api/detection_engine/rule_management'; import type { WarningSchema } from '../../../../../../../common/api/detection_engine'; @@ -22,6 +24,13 @@ import { } from './utils'; import type { ImportRuleActionConnectorsParams, ImportRuleActionConnectorsResult } from './types'; +const NO_ACTION_RESULT = { + success: true, + errors: [], + successCount: 0, + warnings: [], +}; + export const importRuleActionConnectors = async ({ actionConnectors, actionsClient, @@ -30,41 +39,40 @@ export const importRuleActionConnectors = async ({ overwrite, }: ImportRuleActionConnectorsParams): Promise => { try { - const actionConnectorRules = getActionConnectorRules(rules); - const actionsIds: string[] = Object.keys(actionConnectorRules); + const connectorIdToRuleIdsMap = getActionConnectorRules(rules); + const referencedConnectorIds = await filterOutPreconfiguredConnectors( + actionsClient, + Object.keys(connectorIdToRuleIdsMap) + ); - if (!actionsIds.length) - return { - success: true, - errors: [], - successCount: 0, - warnings: [], - }; + if (!referencedConnectorIds.length) { + return NO_ACTION_RESULT; + } - if (overwrite && !actionConnectors.length) - return handleActionsHaveNoConnectors(actionsIds, actionConnectorRules); + if (overwrite && !actionConnectors.length) { + return handleActionsHaveNoConnectors(referencedConnectorIds, connectorIdToRuleIdsMap); + } let actionConnectorsToImport: SavedObject[] = actionConnectors; if (!overwrite) { - const newIdsToAdd = await filterExistingActionConnectors(actionsClient, actionsIds); + const newIdsToAdd = await filterExistingActionConnectors( + actionsClient, + referencedConnectorIds + ); const foundMissingConnectors = checkIfActionsHaveMissingConnectors( actionConnectors, newIdsToAdd, - actionConnectorRules + connectorIdToRuleIdsMap ); if (foundMissingConnectors) return foundMissingConnectors; // filter out existing connectors actionConnectorsToImport = actionConnectors.filter(({ id }) => newIdsToAdd.includes(id)); } - if (!actionConnectorsToImport.length) - return { - success: true, - errors: [], - successCount: 0, - warnings: [], - }; + if (!actionConnectorsToImport.length) { + return NO_ACTION_RESULT; + } const readStream = Readable.from(actionConnectorsToImport); const { success, successCount, successResults, warnings, errors }: SavedObjectsImportResponse = @@ -93,3 +101,25 @@ export const importRuleActionConnectors = async ({ return returnErroredImportResult(error); } }; + +async function fetchPreconfiguredActionConnectors( + actionsClient: ActionsClient +): Promise { + const knownConnectors = await actionsClient.getAll(); + + return knownConnectors.filter((c) => c.isPreconfigured); +} + +async function filterOutPreconfiguredConnectors( + actionsClient: ActionsClient, + connectorsIds: string[] +): Promise { + if (connectorsIds.length === 0) { + return []; + } + + const preconfiguredActionConnectors = await fetchPreconfiguredActionConnectors(actionsClient); + const preconfiguredActionConnectorIds = new Set(preconfiguredActionConnectors.map((c) => c.id)); + + return connectorsIds.filter((id) => !preconfiguredActionConnectorIds.has(id)); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/check_rule_exception_references.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/check_rule_exception_references.test.ts index 2b7996c5094ab..2a249e7d9383a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/check_rule_exception_references.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/check_rule_exception_references.test.ts @@ -13,7 +13,7 @@ describe('checkRuleExceptionReferences', () => { it('returns empty array if rule has no exception list references', () => { const result = checkRuleExceptionReferences({ existingLists: {}, - rule: { ...getImportRulesSchemaMock(), exceptions_list: [] }, + rule: getImportRulesSchemaMock({ exceptions_list: [] }), }); expect(result).toEqual([[], []]); @@ -29,12 +29,11 @@ describe('checkRuleExceptionReferences', () => { type: 'detection', }, }, - rule: { - ...getImportRulesSchemaMock(), + rule: getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), }); expect(result).toEqual([ @@ -53,12 +52,11 @@ describe('checkRuleExceptionReferences', () => { it('removes an exception reference if list not found to exist', () => { const result = checkRuleExceptionReferences({ existingLists: {}, - rule: { - ...getImportRulesSchemaMock(), + rule: getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), }); expect(result).toEqual([ @@ -86,12 +84,11 @@ describe('checkRuleExceptionReferences', () => { type: 'detection', }, }, - rule: { - ...getImportRulesSchemaMock(), + rule: getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), }); expect(result).toEqual([ [ @@ -118,12 +115,11 @@ describe('checkRuleExceptionReferences', () => { type: 'endpoint', }, }, - rule: { - ...getImportRulesSchemaMock(), + rule: getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), }); expect(result).toEqual([ [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/gather_referenced_exceptions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/gather_referenced_exceptions.test.ts index df136fe6cfc8d..1605c745256b2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/gather_referenced_exceptions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/gather_referenced_exceptions.test.ts @@ -53,12 +53,11 @@ describe('get referenced exceptions', () => { it('returns found referenced exception lists', async () => { const result = await getReferencedExceptionLists({ rules: [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ], savedObjectsClient, }); @@ -77,16 +76,14 @@ describe('get referenced exceptions', () => { it('returns found referenced exception lists when first exceptions list is empty array and second list has a value', async () => { const result = await getReferencedExceptionLists({ rules: [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [], - }, - { - ...getImportRulesSchemaMock(), + }), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ], savedObjectsClient, }); @@ -105,18 +102,16 @@ describe('get referenced exceptions', () => { it('returns found referenced exception lists when two rules reference same list', async () => { const result = await getReferencedExceptionLists({ rules: [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, - { - ...getImportRulesSchemaMock(), + }), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ], savedObjectsClient, }); @@ -157,18 +152,16 @@ describe('get referenced exceptions', () => { const result = await getReferencedExceptionLists({ rules: [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [ { id: '456', list_id: 'other-list', namespace_type: 'single', type: 'detection' }, ], - }, - { - ...getImportRulesSchemaMock(), + }), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ], savedObjectsClient, }); @@ -207,45 +200,38 @@ describe('get referenced exceptions', () => { describe('parseReferencdedExceptionsLists', () => { it('should return parsed lists when exception lists are not empty', () => { const res = parseReferencedExceptionsLists([ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ]); expect(res).toEqual([[], [{ listId: 'my-list', namespaceType: 'single' }]]); }); it('should return parsed lists when one empty exception list and one non-empty list', () => { const res = parseReferencedExceptionsLists([ - { - ...getImportRulesSchemaMock(), - exceptions_list: [], - }, - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [] }), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ]); expect(res).toEqual([[], [{ listId: 'my-list', namespaceType: 'single' }]]); }); it('should return parsed lists when two non-empty exception lists reference same list', () => { const res = parseReferencedExceptionsLists([ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, - { - ...getImportRulesSchemaMock(), + }), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ]); expect(res).toEqual([ [], @@ -258,18 +244,16 @@ describe('get referenced exceptions', () => { it('should return parsed lists when two non-empty exception lists reference differet lists', () => { const res = parseReferencedExceptionsLists([ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ exceptions_list: [ { id: '123', list_id: 'my-list', namespace_type: 'single', type: 'detection' }, ], - }, - { - ...getImportRulesSchemaMock(), + }), + getImportRulesSchemaMock({ exceptions_list: [ { id: '456', list_id: 'other-list', namespace_type: 'single', type: 'detection' }, ], - }, + }), ]); expect(res).toEqual([ [], 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 0b601be81dd62..5b097bacf2d9c 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 @@ -74,14 +74,7 @@ describe('importRules', () => { it('creates rule if no matching existing rule found', async () => { const result = await importRules({ - ruleChunks: [ - [ - { - ...getImportRulesSchemaMock(), - rule_id: 'rule-1', - }, - ], - ], + ruleChunks: [[getImportRulesSchemaMock({ rule_id: 'rule-1' })]], rulesResponseAcc: [], mlAuthz, overwriteRules: false, @@ -98,14 +91,7 @@ describe('importRules', () => { clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit()); const result = await importRules({ - ruleChunks: [ - [ - { - ...getImportRulesSchemaMock(), - rule_id: 'rule-1', - }, - ], - ], + ruleChunks: [[getImportRulesSchemaMock({ rule_id: 'rule-1' })]], rulesResponseAcc: [], mlAuthz, overwriteRules: false, @@ -129,10 +115,9 @@ describe('importRules', () => { const result = await importRules({ ruleChunks: [ [ - { - ...getImportRulesSchemaMock(), + getImportRulesSchemaMock({ rule_id: 'rule-1', - }, + }), ], ], rulesResponseAcc: [], @@ -151,14 +136,7 @@ describe('importRules', () => { clients.rulesClient.find.mockRejectedValue(new Error('error reading rule')); const result = await importRules({ - ruleChunks: [ - [ - { - ...getImportRulesSchemaMock(), - rule_id: 'rule-1', - }, - ], - ], + ruleChunks: [[getImportRulesSchemaMock({ rule_id: 'rule-1' })]], rulesResponseAcc: [], mlAuthz, overwriteRules: true, @@ -183,14 +161,7 @@ describe('importRules', () => { (createRules as jest.Mock).mockRejectedValue(new Error('error creating rule')); const result = await importRules({ - ruleChunks: [ - [ - { - ...getImportRulesSchemaMock(), - rule_id: 'rule-1', - }, - ], - ], + ruleChunks: [[getImportRulesSchemaMock({ rule_id: 'rule-1' })]], rulesResponseAcc: [], mlAuthz, overwriteRules: false, @@ -214,14 +185,7 @@ describe('importRules', () => { clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit()); const result = await importRules({ - ruleChunks: [ - [ - { - ...getImportRulesSchemaMock(), - rule_id: 'rule-1', - }, - ], - ], + ruleChunks: [[getImportRulesSchemaMock({ rule_id: 'rule-1' })]], rulesResponseAcc: [], mlAuthz, overwriteRules: true, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_connectors.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_connectors.ts new file mode 100644 index 0000000000000..89af5bb0987ed --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/import_connectors.ts @@ -0,0 +1,516 @@ +/* + * 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 { combineToNdJson, deleteAllRules, getCustomQueryRuleParams } from '../../../utils'; +import { createConnector, deleteConnector, getConnector } from '../../../utils/connectors'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const log = getService('log'); + + describe('@ess @brokenInServerless @skipInQA import action connectors', () => { + const CONNECTOR_ID = '1be16246-642a-4ed8-bfd3-b47f8c7d7055'; + const ANOTHER_CONNECTOR_ID = 'abc16246-642a-4ed8-bfd3-b47f8c7d7055'; + const CUSTOM_ACTION_CONNECTOR = { + id: CONNECTOR_ID, + type: 'action', + updated_at: '2024-02-05T11:52:10.692Z', + created_at: '2024-02-05T11:52:10.692Z', + version: 'WzYsMV0=', + attributes: { + actionTypeId: '.email', + name: 'test-connector', + isMissingSecrets: false, + config: { + from: 'a@test.com', + service: 'other', + host: 'example.com', + port: 123, + secure: false, + hasAuth: false, + tenantId: null, + clientId: null, + oauthTokenUrl: null, + }, + secrets: {}, + }, + references: [], + managed: false, + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '8.3.0', + }; + + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteConnector(supertest, CONNECTOR_ID); + await deleteConnector(supertest, ANOTHER_CONNECTOR_ID); + }); + + describe('overwrite connectors is set to "false"', () => { + it('imports a rule with an action connector', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: CONNECTOR_ID, + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }), + CUSTOM_ACTION_CONNECTOR + ); + + 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); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + action_connectors_success: true, + action_connectors_success_count: 1, + action_connectors_errors: [], + action_connectors_warnings: [], + }); + + expect(await getConnector(supertest, CONNECTOR_ID)).toMatchObject({ + id: CONNECTOR_ID, + name: 'test-connector', + }); + }); + + it('DOES NOT import an action connector without rules', async () => { + const ndjson = combineToNdJson(CUSTOM_ACTION_CONNECTOR); + + 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); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 0, + rules_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], + }); + + await supertest + .get(`/api/actions/connector/${CONNECTOR_ID}`) + .set('kbn-xsrf', 'foo') + .expect(404); + }); + + it('DOES NOT import an action connector when there are no rules referencing it', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: ANOTHER_CONNECTOR_ID, + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }), + { ...CUSTOM_ACTION_CONNECTOR, id: ANOTHER_CONNECTOR_ID }, + CUSTOM_ACTION_CONNECTOR + ); + + 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); + + expect(body).toMatchObject({ + errors: [], + success: true, + success_count: 1, + rules_count: 1, + action_connectors_success: true, + action_connectors_success_count: 1, + action_connectors_errors: [], + action_connectors_warnings: [], + }); + + await supertest + .get(`/api/actions/connector/${CONNECTOR_ID}`) + .set('kbn-xsrf', 'foo') + .expect(404); + }); + + it('DOES NOT return an error when rule actions reference a preconfigured connector', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: 'my-test-email', + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }) + ); + + 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); + + 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: [], + }); + }); + + /** + * When importing an action connector, if its `id` matches with an existing one, the type and config isn't checked. + * In fact, the connector being imported can have a different type and configuration, and its creation will be skipped. + */ + it('skips importing already existing action connectors', async () => { + await createConnector( + supertest, + { + connector_type_id: '.webhook', + name: 'test-connector', + config: { + // checkout `x-pack/test/security_solution_api_integration/config/ess/config.base.ts` for configuration + // `some.non.existent.com` must be set as an allowed host + url: 'https://some.non.existent.com', + method: 'post', + }, + secrets: {}, + }, + CONNECTOR_ID + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: CONNECTOR_ID, + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }), + CUSTOM_ACTION_CONNECTOR + ); + + 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); + + 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: [], + }); + + expect(await getConnector(supertest, CONNECTOR_ID)).toMatchObject({ + id: CONNECTOR_ID, + name: 'test-connector', + }); + }); + + it('returns an error when connector is missing in ndjson', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: CONNECTOR_ID, + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }) + ); + + 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); + + expect(body).toMatchObject({ + errors: [ + { + error: { + message: `1 connector is missing. Connector id missing is: ${CONNECTOR_ID}`, + status_code: 404, + }, + id: CONNECTOR_ID, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 0, + rules_count: 1, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: `1 connector is missing. Connector id missing is: ${CONNECTOR_ID}`, + status_code: 404, + }, + id: CONNECTOR_ID, + rule_id: 'rule-1', + }, + ], + action_connectors_warnings: [], + }); + }); + }); + + describe('overwrite connectors is set to "true"', () => { + it('overwrites existing connector', async () => { + await createConnector( + supertest, + { + connector_type_id: '.webhook', + name: 'existing-connector', + config: { + // checkout `x-pack/test/security_solution_api_integration/config/ess/config.base.ts` for configuration + // `some.non.existent.com` must be set as an allowed host + url: 'https://some.non.existent.com', + method: 'post', + }, + secrets: {}, + }, + CONNECTOR_ID + ); + + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: CONNECTOR_ID, + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }), + { + ...CUSTOM_ACTION_CONNECTOR, + attributes: { ...CUSTOM_ACTION_CONNECTOR.attributes, name: 'updated-connector' }, + } + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite_action_connectors=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, + action_connectors_success: true, + action_connectors_success_count: 1, + action_connectors_errors: [], + action_connectors_warnings: [], + }); + + expect(await getConnector(supertest, CONNECTOR_ID)).toMatchObject({ + id: CONNECTOR_ID, + name: 'updated-connector', + }); + }); + + it('returns an error when connector is missing in ndjson', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: CONNECTOR_ID, + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite_action_connectors=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: [ + { + error: { + message: `1 connector is missing. Connector id missing is: ${CONNECTOR_ID}`, + status_code: 404, + }, + id: CONNECTOR_ID, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 0, + rules_count: 1, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: `1 connector is missing. Connector id missing is: ${CONNECTOR_ID}`, + status_code: 404, + }, + id: CONNECTOR_ID, + rule_id: 'rule-1', + }, + ], + action_connectors_warnings: [], + }); + }); + + it('DOES NOT return an error when rule actions reference a preconfigured connector', async () => { + const ndjson = combineToNdJson( + getCustomQueryRuleParams({ + rule_id: 'rule-1', + name: 'Rule 1', + actions: [ + { + group: 'default', + id: 'my-test-email', + params: { + message: 'Some message', + to: ['test@test.com'], + subject: 'Test', + }, + action_type_id: '.email', + uuid: 'fda6721b-d3a4-4d2c-ad0c-18893759e096', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + }, + ], + }) + ); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite_action_connectors=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, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], + }); + }); + }); + }); +}; 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 index f2240c96dfe5b..2fc4754a3f230 100644 --- 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 @@ -14,5 +14,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./import_export_rules')); loadTestFile(require.resolve('./import_rules')); loadTestFile(require.resolve('./import_rules_ess')); + loadTestFile(require.resolve('./import_connectors')); }); } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/combine_to_ndjson.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/combine_to_ndjson.ts new file mode 100644 index 0000000000000..fc2baff9c365f --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/combine_to_ndjson.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function combineToNdJson(...parts: unknown[]): string { + return parts.map((p) => JSON.stringify(p)).join('\n'); +} 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 new file mode 100644 index 0000000000000..9c3f54e019653 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/create_connector.ts @@ -0,0 +1,27 @@ +/* + * 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'; + +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 { + await supertest + .post(`/api/actions/connector/${id}`) + .set('kbn-xsrf', 'foo') + .send(connector) + .expect(200); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/delete_connector.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/delete_connector.ts new file mode 100644 index 0000000000000..683f845fd8bf8 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/delete_connector.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 type SuperTest from 'supertest'; + +export function deleteConnector( + supertest: SuperTest.SuperTest, + connectorId: string +): SuperTest.Test { + return supertest.delete(`/api/actions/connector/${connectorId}`).set('kbn-xsrf', 'foo'); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_connector.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_connector.ts new file mode 100644 index 0000000000000..8f7e4830372f9 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/get_connector.ts @@ -0,0 +1,21 @@ +/* + * 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 { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import type SuperTest from 'supertest'; + +export async function getConnector( + supertest: SuperTest.SuperTest, + connectorId: string +): Promise { + const response = await supertest + .get(`/api/actions/connector/${connectorId}`) + .set('kbn-xsrf', 'foo') + .expect(200); + + return response.body; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/index.ts new file mode 100644 index 0000000000000..be89cd4a94d47 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/connectors/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './create_connector'; +export * from './get_connector'; +export * from './delete_connector'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts index d51fce39e3410..d86075bb41f60 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts @@ -23,3 +23,4 @@ export * from './get_stats'; export * from './get_detection_metrics_from_body'; export * from './get_stats_url'; export * from './retry'; +export * from './combine_to_ndjson';