From 78cb16c595e6f0066479b07deb6766426bdac3c3 Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Wed, 14 Feb 2024 13:54:51 +0100 Subject: [PATCH 1/8] [8.12] [Security Solution] Fix importing rules referencing preconfigured connectors (#176284) (#176887) # Backport This will backport the following commits from `main` to `8.12`: - [[Security Solution] Fix importing rules referencing preconfigured connectors (#176284)](https://github.com/elastic/kibana/pull/176284) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) --- .../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 +- .../group10/import_connectors.ts | 517 ++++++++++++++++++ .../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 + .../utils/get_custom_query_rule_params.ts | 36 ++ .../utils/index.ts | 2 + .../alerts/migrations/index.ts | 2 +- .../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 + 23 files changed, 1142 insertions(+), 401 deletions(-) create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_connectors.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/combine_to_ndjson.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/connectors/create_connector.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/connectors/delete_connector.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/connectors/get_connector.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/connectors/index.ts create mode 100644 x-pack/test/detection_engine_api_integration/utils/get_custom_query_rule_params.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/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 new file mode 100644 index 0000000000000..0dcda3af45510 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_connectors.ts @@ -0,0 +1,517 @@ +/* + * 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 { createConnector, deleteConnector, getConnector } from '../../utils/connectors'; +import { combineToNdJson, deleteAllRules, getCustomQueryRuleParams } 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 @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/detection_engine_api_integration/utils/combine_to_ndjson.ts b/x-pack/test/detection_engine_api_integration/utils/combine_to_ndjson.ts new file mode 100644 index 0000000000000..fc2baff9c365f --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/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/detection_engine_api_integration/utils/connectors/create_connector.ts b/x-pack/test/detection_engine_api_integration/utils/connectors/create_connector.ts new file mode 100644 index 0000000000000..9c3f54e019653 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/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/detection_engine_api_integration/utils/connectors/delete_connector.ts b/x-pack/test/detection_engine_api_integration/utils/connectors/delete_connector.ts new file mode 100644 index 0000000000000..683f845fd8bf8 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/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/detection_engine_api_integration/utils/connectors/get_connector.ts b/x-pack/test/detection_engine_api_integration/utils/connectors/get_connector.ts new file mode 100644 index 0000000000000..8f7e4830372f9 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/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/detection_engine_api_integration/utils/connectors/index.ts b/x-pack/test/detection_engine_api_integration/utils/connectors/index.ts new file mode 100644 index 0000000000000..be89cd4a94d47 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/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/detection_engine_api_integration/utils/get_custom_query_rule_params.ts b/x-pack/test/detection_engine_api_integration/utils/get_custom_query_rule_params.ts new file mode 100644 index 0000000000000..d4773a4f7e516 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/get_custom_query_rule_params.ts @@ -0,0 +1,36 @@ +/* + * 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 } from '@kbn/security-solution-plugin/common/api/detection_engine'; + +type CreateRulePropsRewrites = Partial>; + +/** + * Returns custom query rule params that is easy for most basic testing of output of alerts. + * It starts out in an disabled state. The 'from' is set very far back to test the basics of signal + * creation and testing by getting all the signals at once. + * + * @param rewrites rule params rewrites, see QueryRuleCreateProps for possible fields + */ +export function getCustomQueryRuleParams( + rewrites?: CreateRulePropsRewrites +): QueryRuleCreateProps { + return { + type: 'query', + query: '*:*', + name: 'Custom query rule', + description: 'Custom query rule description', + risk_score: 1, + rule_id: 'rule-1', + severity: 'high', + index: ['logs-*'], + interval: '100m', + from: 'now-6m', + enabled: false, + ...rewrites, + }; +} 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 47823f76ea6b3..7a17f692540ce 100644 --- a/x-pack/test/detection_engine_api_integration/utils/index.ts +++ b/x-pack/test/detection_engine_api_integration/utils/index.ts @@ -89,3 +89,5 @@ export * from './prebuilt_rules/install_mock_prebuilt_rules'; export * from './prebuilt_rules/install_prebuilt_rules_and_timelines'; export * from './get_legacy_action_so'; export * from './delete_all_exceptions'; +export * from './combine_to_ndjson'; +export * from './get_custom_query_rule_params'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/migrations/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/migrations/index.ts index 115c8dbeab1f2..f57649c8ebbb0 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/migrations/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/migrations/index.ts @@ -11,6 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./create_alerts_migrations')); loadTestFile(require.resolve('./delete_alerts_migrations')); loadTestFile(require.resolve('./finalize_alerts_migrations')); - loadTestFile(require.resolve('./finalize_alerts_migrations')); + loadTestFile(require.resolve('./get_alerts_migration_status')); }); } 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 afcdf130392f1..c16c3dc1f3674 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 @@ -25,3 +25,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'; From 4884e48e60639b423c8a337e6c06271f499095ed Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:31:12 -0500 Subject: [PATCH 2/8] [8.12] [Security Solution] Fix broken Rule Filters components when content is extremely long and when alias is present (#176590) (#176928) # Backport This will backport the following commits from `main` to `8.12`: - [[Security Solution] Fix broken Rule Filters components when content is extremely long and when alias is present (#176590)](https://github.com/elastic/kibana/pull/176590) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Juan Pablo Djeredjian --- .../pages/rule_details/index.tsx | 94 ++++++++++--------- .../rule_details/rule_definition_section.tsx | 35 +++---- 2 files changed, 71 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index e8a97e974431a..a9b6d63d852d4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -163,6 +163,14 @@ const StyledMinHeightTabContainer = styled.div` min-height: 800px; `; +/** + * Wrapper for the About, Definition and Schedule sections. + * - Allows for overflow wrapping of extremely long text, that might otherwise break the layout. + */ +const RuleFieldsSectionWrapper = styled.div` + overflow-wrap: anywhere; +`; + type DetectionEngineComponentProps = PropsFromRedux; const RuleDetailsPageComponent: React.FC = ({ @@ -648,50 +656,52 @@ const RuleDetailsPageComponent: React.FC = ({ {ruleError} - - - {rule !== null && ( - - )} - - - - - - - {rule !== null && !isStartingJobs && ( - - )} - - - - - - {rule != null && } - - - {hasActions && ( - - - + + + + {rule !== null && ( + + )} + + + + + + + {rule !== null && !isStartingJobs && ( + + )} - )} - - - + + + + {rule != null && } + + + {hasActions && ( + + + + + + )} + + + + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx index cb37caa661fda..7815afa72c959 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx @@ -90,22 +90,25 @@ const Filters = ({ filters, dataViewId, index, 'data-test-subj': dataTestSubj }: return ( - {flattenedFilters.map((filter, idx) => ( - - - {indexPattern != null ? ( - - ) : ( - - )} - - - ))} + {flattenedFilters.map((filter, idx) => { + const displayContent = filter.meta.alias ? ( + filter.meta.alias + ) : ( + + ); + return ( + + + {indexPattern != null ? displayContent : } + + + ); + })} ); }; From e446672754afbedc01086763b554955933298f35 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 14 Feb 2024 13:07:49 -0500 Subject: [PATCH 3/8] [8.12] [Discover][Alerts] Fix Discover results when alert excludes matches from previous runs (#176690) (#176931) # Backport This will backport the following commits from `main` to `8.12`: - [[Discover][Alerts] Fix Discover results when alert excludes matches from previous runs (#176690)](https://github.com/elastic/kibana/pull/176690) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Julia Rechkunova --- .../lib/fetch_search_source_query.test.ts | 152 ++++++++++++++++-- .../es_query/lib/fetch_search_source_query.ts | 40 +++-- 2 files changed, 164 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.test.ts index a753c34908a20..ca5a2736408d4 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.test.ts @@ -7,14 +7,22 @@ import { OnlySearchSourceRuleParams } from '../types'; import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; -import { updateSearchSource, getSmallerDataViewSpec } from './fetch_search_source_query'; +import { + updateSearchSource, + generateLink, + updateFilterReferences, + getSmallerDataViewSpec, +} from './fetch_search_source_query'; import { createStubDataView, stubbedSavedObjectIndexPattern, } from '@kbn/data-views-plugin/common/data_view.stub'; -import { DataView } from '@kbn/data-views-plugin/common'; +import { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks'; import { Comparator } from '../../../../common/comparator_types'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; +import { LocatorPublic } from '@kbn/share-plugin/common'; const createDataView = () => { const id = 'test-id'; @@ -55,26 +63,27 @@ const defaultParams: OnlySearchSourceRuleParams = { }; describe('fetchSearchSourceQuery', () => { - describe('updateSearchSource', () => { - const dataViewMock = createDataView(); - afterAll(() => { - jest.resetAllMocks(); - }); + const dataViewMock = createDataView(); - const fakeNow = new Date('2020-02-09T23:15:41.941Z'); + afterAll(() => { + jest.resetAllMocks(); + }); - beforeAll(() => { - jest.resetAllMocks(); - global.Date.now = jest.fn(() => fakeNow.getTime()); - }); + const fakeNow = new Date('2020-02-09T23:15:41.941Z'); + + beforeAll(() => { + jest.resetAllMocks(); + global.Date.now = jest.fn(() => fakeNow.getTime()); + }); + describe('updateSearchSource', () => { it('without latest timestamp', async () => { const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] }; const searchSourceInstance = createSearchSourceMock({ index: dataViewMock }); const { dateStart, dateEnd } = getTimeRange(); - const searchSource = updateSearchSource( + const { searchSource, filterToExcludeHitsFromPreviousRun } = updateSearchSource( searchSourceInstance, dataViewMock, params, @@ -83,6 +92,7 @@ describe('fetchSearchSourceQuery', () => { dateEnd ); const searchRequest = searchSource.getSearchRequestBody(); + expect(filterToExcludeHitsFromPreviousRun).toBe(null); expect(searchRequest.size).toMatchInlineSnapshot(`100`); expect(searchRequest.query).toMatchInlineSnapshot(` Object { @@ -113,7 +123,7 @@ describe('fetchSearchSourceQuery', () => { const searchSourceInstance = createSearchSourceMock({ index: dataViewMock }); const { dateStart, dateEnd } = getTimeRange(); - const searchSource = updateSearchSource( + const { searchSource, filterToExcludeHitsFromPreviousRun } = updateSearchSource( searchSourceInstance, dataViewMock, params, @@ -122,6 +132,23 @@ describe('fetchSearchSourceQuery', () => { dateEnd ); const searchRequest = searchSource.getSearchRequestBody(); + expect(filterToExcludeHitsFromPreviousRun).toMatchInlineSnapshot(` + Object { + "meta": Object { + "field": "time", + "index": "test-id", + "params": Object {}, + }, + "query": Object { + "range": Object { + "time": Object { + "format": "strict_date_optional_time", + "gt": "2020-02-09T23:12:41.941Z", + }, + }, + }, + } + `); expect(searchRequest.size).toMatchInlineSnapshot(`100`); expect(searchRequest.query).toMatchInlineSnapshot(` Object { @@ -160,7 +187,7 @@ describe('fetchSearchSourceQuery', () => { const searchSourceInstance = createSearchSourceMock({ index: dataViewMock }); const { dateStart, dateEnd } = getTimeRange(); - const searchSource = updateSearchSource( + const { searchSource, filterToExcludeHitsFromPreviousRun } = updateSearchSource( searchSourceInstance, dataViewMock, params, @@ -169,6 +196,7 @@ describe('fetchSearchSourceQuery', () => { dateEnd ); const searchRequest = searchSource.getSearchRequestBody(); + expect(filterToExcludeHitsFromPreviousRun).toBe(null); expect(searchRequest.size).toMatchInlineSnapshot(`100`); expect(searchRequest.query).toMatchInlineSnapshot(` Object { @@ -199,7 +227,7 @@ describe('fetchSearchSourceQuery', () => { const searchSourceInstance = createSearchSourceMock({ index: dataViewMock }); const { dateStart, dateEnd } = getTimeRange(); - const searchSource = updateSearchSource( + const { searchSource, filterToExcludeHitsFromPreviousRun } = updateSearchSource( searchSourceInstance, dataViewMock, params, @@ -208,6 +236,7 @@ describe('fetchSearchSourceQuery', () => { dateEnd ); const searchRequest = searchSource.getSearchRequestBody(); + expect(filterToExcludeHitsFromPreviousRun).toBe(null); expect(searchRequest.size).toMatchInlineSnapshot(`100`); expect(searchRequest.query).toMatchInlineSnapshot(` Object { @@ -244,7 +273,7 @@ describe('fetchSearchSourceQuery', () => { const searchSourceInstance = createSearchSourceMock({ index: dataViewMock }); const { dateStart, dateEnd } = getTimeRange(); - const searchSource = updateSearchSource( + const { searchSource } = updateSearchSource( searchSourceInstance, dataViewMock, params, @@ -307,6 +336,95 @@ describe('fetchSearchSourceQuery', () => { }); }); + describe('generateLink', () => { + it('should include additional time filter', async () => { + const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] }; + + const searchSourceInstance = createSearchSourceMock({ index: dataViewMock }); + + const { dateStart, dateEnd } = getTimeRange(); + const { filterToExcludeHitsFromPreviousRun } = updateSearchSource( + searchSourceInstance, + dataViewMock, + params, + '2020-02-09T23:12:41.941Z', + dateStart, + dateEnd + ); + + expect(filterToExcludeHitsFromPreviousRun).toMatchInlineSnapshot(` + Object { + "meta": Object { + "field": "time", + "index": "test-id", + "params": Object {}, + }, + "query": Object { + "range": Object { + "time": Object { + "format": "strict_date_optional_time", + "gt": "2020-02-09T23:12:41.941Z", + }, + }, + }, + } + `); + + const locatorMock = { + getRedirectUrl: jest.fn(() => '/app/r?l=DISCOVER_APP_LOCATOR'), + } as unknown as LocatorPublic; + + const dataViews = { + ...dataViewPluginMocks.createStartContract(), + create: async (spec: DataViewSpec) => + new DataView({ spec, fieldFormats: fieldFormatsMock }), + }; + + const linkWithoutExcludedRuns = await generateLink( + searchSourceInstance, + locatorMock, + dataViews, + dataViewMock, + dateStart, + dateEnd, + 'test1', + null + ); + + expect(linkWithoutExcludedRuns).toBe('test1/app/r?l=DISCOVER_APP_LOCATOR'); + expect(locatorMock.getRedirectUrl).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [], + }) + ); + + const linkWithExcludedRuns = await generateLink( + searchSourceInstance, + locatorMock, + dataViews, + dataViewMock, + dateStart, + dateEnd, + 'test2', + filterToExcludeHitsFromPreviousRun + ); + + expect(linkWithExcludedRuns).toBe('test2/app/r?l=DISCOVER_APP_LOCATOR'); + expect(locatorMock.getRedirectUrl).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + filters: expect.arrayContaining( + updateFilterReferences( + [filterToExcludeHitsFromPreviousRun!], + dataViewMock.id!, + undefined + ) + ), + }) + ); + }); + }); + describe('getSmallerDataViewSpec', () => { it('should remove "count"s but keep other props like "customLabel"', async () => { const fieldsMap = { diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.ts index 455ce4c7dc9bc..84c0dbf6ef5f2 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/lib/fetch_search_source_query.ts @@ -60,7 +60,7 @@ export async function fetchSearchSourceQuery({ const initialSearchSource = await searchSourceClient.create(params.searchConfiguration); const index = initialSearchSource.getField('index') as DataView; - const searchSource = updateSearchSource( + const { searchSource, filterToExcludeHitsFromPreviousRun } = updateSearchSource( initialSearchSource, index, params, @@ -85,7 +85,8 @@ export async function fetchSearchSourceQuery({ index, dateStart, dateEnd, - spacePrefix + spacePrefix, + filterToExcludeHitsFromPreviousRun ); return { link, @@ -104,7 +105,7 @@ export function updateSearchSource( dateStart: string, dateEnd: string, alertLimit?: number -) { +): { searchSource: ISearchSource; filterToExcludeHitsFromPreviousRun: Filter | null } { const isGroupAgg = isGroupAggregation(params.termField); const timeFieldName = params.timeField || index.timeFieldName; @@ -123,16 +124,17 @@ export function updateSearchSource( ), ]; + let filterToExcludeHitsFromPreviousRun = null; if (params.excludeHitsFromPreviousRun) { if (latestTimestamp && latestTimestamp > dateStart) { - // add additional filter for documents with a timestamp greater then + // add additional filter for documents with a timestamp greater than // the timestamp of the previous run, so that those documents are not counted twice - const addTimeRangeField = buildRangeFilter( + filterToExcludeHitsFromPreviousRun = buildRangeFilter( field!, { gt: latestTimestamp, format: 'strict_date_optional_time' }, index ); - filters.push(addTimeRangeField); + filters.push(filterToExcludeHitsFromPreviousRun); } } @@ -164,19 +166,31 @@ export function updateSearchSource( ...(isGroupAgg ? { topHitsSize: params.size } : {}), }) ); - return searchSourceChild; + return { + searchSource: searchSourceChild, + filterToExcludeHitsFromPreviousRun, + }; } -async function generateLink( +export async function generateLink( searchSource: ISearchSource, discoverLocator: LocatorPublic, dataViews: DataViewsContract, dataViewToUpdate: DataView, dateStart: string, dateEnd: string, - spacePrefix: string + spacePrefix: string, + filterToExcludeHitsFromPreviousRun: Filter | null ) { - const prevFilters = searchSource.getField('filter') as Filter[]; + const prevFilters = [...((searchSource.getField('filter') as Filter[]) || [])]; + + if (filterToExcludeHitsFromPreviousRun) { + // Using the same additional filter as in the alert check above. + // We cannot simply pass `latestTimestamp` to `timeRange.from` Discover locator params + // as that would include `latestTimestamp` itself in the Discover results which would be wrong. + // Results should be after `latestTimestamp` and within `dateStart` and `dateEnd`. + prevFilters.push(filterToExcludeHitsFromPreviousRun); + } // make new adhoc data view const newDataView = await dataViews.create({ @@ -202,7 +216,11 @@ async function generateLink( return start + spacePrefix + '/app' + end; } -function updateFilterReferences(filters: Filter[], fromDataView: string, toDataView: string) { +export function updateFilterReferences( + filters: Filter[], + fromDataView: string, + toDataView: string | undefined +) { return (filters || []).map((filter) => { if (filter.meta.index === fromDataView) { return { From f5bd489c5ff9c676c4f861c42da6ea99ae350832 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 14 Feb 2024 17:46:40 -0500 Subject: [PATCH 4/8] [8.12] [Bug][Investigations] - Fix slow timeline queries (#176838) (#176956) # Backport This will backport the following commits from `main` to `8.12`: - [[Bug][Investigations] - Fix slow timeline queries (#176838)](https://github.com/elastic/kibana/pull/176838) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Michael Olorunnisola --- .../api/search_strategy/timeline/eql.test.ts | 109 ++++++++++++++++++ .../timeline/events_all.test.ts | 76 ++++++++++++ .../timeline/events_details.test.ts | 55 +++++++++ .../timeline/events_last_event_time.test.ts | 69 +++++++++++ .../api/search_strategy/timeline/kpi.test.ts | 51 ++++++++ .../timeline/mocks/base_timeline_request.ts | 20 ++++ .../timeline/request_basic.test.ts | 53 +++++++++ .../search_strategy/timeline/request_basic.ts | 1 + 8 files changed, 434 insertions(+) create mode 100644 x-pack/plugins/timelines/common/api/search_strategy/timeline/eql.test.ts create mode 100644 x-pack/plugins/timelines/common/api/search_strategy/timeline/events_all.test.ts create mode 100644 x-pack/plugins/timelines/common/api/search_strategy/timeline/events_details.test.ts create mode 100644 x-pack/plugins/timelines/common/api/search_strategy/timeline/events_last_event_time.test.ts create mode 100644 x-pack/plugins/timelines/common/api/search_strategy/timeline/kpi.test.ts create mode 100644 x-pack/plugins/timelines/common/api/search_strategy/timeline/mocks/base_timeline_request.ts create mode 100644 x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.test.ts diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/eql.test.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/eql.test.ts new file mode 100644 index 0000000000000..914bb685cd5f2 --- /dev/null +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/eql.test.ts @@ -0,0 +1,109 @@ +/* + * 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 { timelineEqlRequestOptionsSchema } from './eql'; +import { mockBaseTimelineRequest } from './mocks/base_timeline_request'; + +const mockEqlRequestOptions = { + ...mockBaseTimelineRequest, + filterQuery: 'sequence\n[any where true]\n[any where true]', + eventCategoryField: 'event.category', + tiebreakerField: '', + fieldRequested: [ + '@timestamp', + 'message', + 'event.category', + 'event.action', + 'host.name', + 'source.ip', + 'destination.ip', + 'user.name', + '@timestamp', + 'kibana.alert.workflow_status', + 'kibana.alert.workflow_tags', + 'kibana.alert.workflow_assignee_ids', + 'kibana.alert.group.id', + 'kibana.alert.original_time', + 'kibana.alert.building_block_type', + 'kibana.alert.rule.from', + 'kibana.alert.rule.name', + 'kibana.alert.rule.to', + 'kibana.alert.rule.uuid', + 'kibana.alert.rule.rule_id', + 'kibana.alert.rule.type', + 'kibana.alert.suppression.docs_count', + 'kibana.alert.original_event.kind', + 'kibana.alert.original_event.module', + 'file.path', + 'file.Ext.code_signature.subject_name', + 'file.Ext.code_signature.trusted', + 'file.hash.sha256', + 'host.os.family', + 'event.code', + 'process.entry_leader.entity_id', + ], + language: 'eql', + pagination: { + activePage: 0, + querySize: 25, + }, + runtimeMappings: {}, + size: 100, + sort: [ + { + direction: 'asc', + esTypes: ['date'], + field: '@timestamp', + type: 'date', + }, + ], + timerange: { + from: '2018-02-12T20:39:22.229Z', + interval: '12h', + to: '2024-02-13T20:39:22.229Z', + }, + timestampField: '@timestamp', +}; + +describe('timelineEqlRequestOptionsSchema', () => { + it('should correctly parse the last eql request object without unknown fields', () => { + expect(timelineEqlRequestOptionsSchema.parse(mockEqlRequestOptions)).toEqual( + mockEqlRequestOptions + ); + }); + + it('should correctly parse the last eql request object and remove unknown fields', () => { + const invalidEqlRequest = { + ...mockEqlRequestOptions, + unknownField: 'should-be-removed', + }; + expect(timelineEqlRequestOptionsSchema.parse(invalidEqlRequest)).toEqual(mockEqlRequestOptions); + }); + + it('should correctly error if an incorrect field type is provided for a schema key', () => { + const invalidEqlRequest = { + ...mockEqlRequestOptions, + fieldRequested: 123, + }; + + expect(() => { + timelineEqlRequestOptionsSchema.parse(invalidEqlRequest); + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"array\\", + \\"received\\": \\"number\\", + \\"path\\": [ + \\"fieldRequested\\" + ], + \\"message\\": \\"Expected array, received number\\" + } + ]" + `); + }); +}); diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_all.test.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_all.test.ts new file mode 100644 index 0000000000000..1c69e529c66f7 --- /dev/null +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_all.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { timelineEventsAllSchema } from './events_all'; +import { mockBaseTimelineRequest } from './mocks/base_timeline_request'; + +const mockEventsAllRequest = { + ...mockBaseTimelineRequest, + factoryQueryType: 'eventsAll', + excludeEcsData: false, + pagination: { activePage: 0, querySize: 25 }, + fieldRequested: [ + '@timestamp', + '_index', + 'message', + 'host.name', + 'event.module', + 'agent.type', + 'event.dataset', + 'event.action', + 'user.name', + 'source.ip', + 'destination.ip', + ], + sort: [ + { + field: '@timestamp', + type: 'date', + direction: 'desc', + esTypes: [], + }, + ], + fields: [], + language: 'kuery', +}; + +describe('timelineEventsAllSchema', () => { + it('should correctly parse the events request object', () => { + expect(timelineEventsAllSchema.parse(mockEventsAllRequest)).toEqual(mockEventsAllRequest); + }); + + it('should correctly parse the events request object and remove unknown fields', () => { + const invalidEventsRequest = { + ...mockEventsAllRequest, + unknownField: 'shouldBeRemoved', + }; + expect(timelineEventsAllSchema.parse(invalidEventsRequest)).toEqual(mockEventsAllRequest); + }); + + it('should correctly error if an incorrect field type is provided for a schema key', () => { + const invalidEventsRequest = { + ...mockEventsAllRequest, + excludeEcsData: 'notABoolean', + }; + + expect(() => { + timelineEventsAllSchema.parse(invalidEventsRequest); + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"boolean\\", + \\"received\\": \\"string\\", + \\"path\\": [ + \\"excludeEcsData\\" + ], + \\"message\\": \\"Expected boolean, received string\\" + } + ]" + `); + }); +}); diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_details.test.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_details.test.ts new file mode 100644 index 0000000000000..57adc28cf8434 --- /dev/null +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_details.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { timelineEventsDetailsSchema } from './events_details'; + +const mockEventsDetails = { + entityType: 'events', + indexName: 'test-large-index', + eventId: 'enfXnY0Byt9Ce9tO1aWh', + factoryQueryType: 'eventsDetails', + runtimeMappings: {}, +}; + +describe('timelineEventsDetailsSchema', () => { + it('should correctly parse the event details request schema', () => { + expect(timelineEventsDetailsSchema.parse(mockEventsDetails)).toEqual(mockEventsDetails); + }); + + it('should correctly parse the event details request schema and remove unknown fields', () => { + const invalidEventsDetailsRequest = { + ...mockEventsDetails, + unknownField: 'should-be-removed', + }; + expect(timelineEventsDetailsSchema.parse(invalidEventsDetailsRequest)).toEqual( + mockEventsDetails + ); + }); + + it('should correctly error if an incorrect field type is provided for a schema key', () => { + const invalidEventsDetailsRequest = { + ...mockEventsDetails, + indexName: 123, + }; + + expect(() => { + timelineEventsDetailsSchema.parse(invalidEventsDetailsRequest); + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"number\\", + \\"path\\": [ + \\"indexName\\" + ], + \\"message\\": \\"Expected string, received number\\" + } + ]" + `); + }); +}); diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_last_event_time.test.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_last_event_time.test.ts new file mode 100644 index 0000000000000..1ccacb265416e --- /dev/null +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/events_last_event_time.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { timelineEventsLastEventTimeRequestSchema } from './events_last_event_time'; +import { mockBaseTimelineRequest } from './mocks/base_timeline_request'; + +const mockEventsLastEventTimeRequest = { + ...mockBaseTimelineRequest, + // Remove fields that are omitted in the schema + runtimeMappings: undefined, + filterQuery: undefined, + timerange: undefined, + // Add eventsLastEventTime specific fields + factoryQueryType: 'eventsLastEventTime', + indexKey: 'hosts', + details: {}, +}; + +describe('timelineEventsLastEventTimeRequestSchema', () => { + it('should correctly parse the last event time request object without unknown fields', () => { + expect(timelineEventsLastEventTimeRequestSchema.parse(mockEventsLastEventTimeRequest)).toEqual( + mockEventsLastEventTimeRequest + ); + }); + + it('should correctly parse the last event time request object and remove unknown fields', () => { + const invalidEventsDetailsRequest = { + ...mockEventsLastEventTimeRequest, + unknownField: 'should-be-removed', + }; + expect(timelineEventsLastEventTimeRequestSchema.parse(invalidEventsDetailsRequest)).toEqual( + mockEventsLastEventTimeRequest + ); + }); + + it('should correctly error if an incorrect field type is provided for a schema key', () => { + const invalidEventsDetailsRequest = { + ...mockEventsLastEventTimeRequest, + indexKey: 'unknown-key', + }; + + expect(() => { + timelineEventsLastEventTimeRequestSchema.parse(invalidEventsDetailsRequest); + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"received\\": \\"unknown-key\\", + \\"code\\": \\"invalid_enum_value\\", + \\"options\\": [ + \\"hostDetails\\", + \\"hosts\\", + \\"users\\", + \\"userDetails\\", + \\"ipDetails\\", + \\"network\\" + ], + \\"path\\": [ + \\"indexKey\\" + ], + \\"message\\": \\"Invalid enum value. Expected 'hostDetails' | 'hosts' | 'users' | 'userDetails' | 'ipDetails' | 'network', received 'unknown-key'\\" + } + ]" + `); + }); +}); diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/kpi.test.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/kpi.test.ts new file mode 100644 index 0000000000000..ade3b954e9210 --- /dev/null +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/kpi.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { timelineKpiRequestOptionsSchema } from './kpi'; +import { mockBaseTimelineRequest } from './mocks/base_timeline_request'; + +const mockKpiRequest = { + ...mockBaseTimelineRequest, + factoryQueryType: 'eventsKpi', +}; + +describe('timelineKpiRequestOptionsSchema', () => { + it('should correctly parse the events kpi request object', () => { + expect(timelineKpiRequestOptionsSchema.parse(mockKpiRequest)).toEqual(mockKpiRequest); + }); + + it('should correctly parse the events kpi request object and remove unknown fields', () => { + const invalidKpiRequest = { + ...mockKpiRequest, + unknownField: 'shouldBeRemoved', + }; + expect(timelineKpiRequestOptionsSchema.parse(invalidKpiRequest)).toEqual(mockKpiRequest); + }); + + it('should correctly error if an incorrect field type is provided for a schema key', () => { + const invalidKpiRequest = { + ...mockKpiRequest, + factoryQueryType: 'someOtherType', + }; + + expect(() => { + timelineKpiRequestOptionsSchema.parse(invalidKpiRequest); + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"received\\": \\"someOtherType\\", + \\"code\\": \\"invalid_literal\\", + \\"expected\\": \\"eventsKpi\\", + \\"path\\": [ + \\"factoryQueryType\\" + ], + \\"message\\": \\"Invalid literal value, expected \\\\\\"eventsKpi\\\\\\"\\" + } + ]" + `); + }); +}); diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/mocks/base_timeline_request.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/mocks/base_timeline_request.ts new file mode 100644 index 0000000000000..c1ab5fc35b641 --- /dev/null +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/mocks/base_timeline_request.ts @@ -0,0 +1,20 @@ +/* + * 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 const mockBaseTimelineRequest = { + id: 'Fnh1dVQ4SDRTUldtRXpUcDEwZXliWHcdZXdlWVBFWkVSWHVIdzY4a19JbFRvUTozMzgzNzk=', + defaultIndex: ['*-large-index'], + filterQuery: + '{"bool":{"must":[],"filter":[{"bool":{"should":[{"exists":{"field":"host.name"}}],"minimum_should_match":1}},{"range":{"@timestamp":{"gte":"2019-02-13T15:39:10.392Z","lt":"2024-02-14T04:59:59.999Z","format":"strict_date_optional_time"}}}],"should":[],"must_not":[]}}', + runtimeMappings: {}, + timerange: { + interval: '12h', + from: '2019-02-13T15:39:10.392Z', + to: '2024-02-14T04:59:59.999Z', + }, + entityType: 'events', +}; diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.test.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.test.ts new file mode 100644 index 0000000000000..2a6b4c59f95e6 --- /dev/null +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { timelineRequestBasicOptionsSchema } from './request_basic'; +import { mockBaseTimelineRequest } from './mocks/base_timeline_request'; + +describe('timelineRequestBasicOptionsSchema', () => { + it('should correctly parse the base timeline request object', () => { + expect(timelineRequestBasicOptionsSchema.parse(mockBaseTimelineRequest)).toEqual( + mockBaseTimelineRequest + ); + }); + + it('should correctly parse the base timeline request object and remove unknown fields', () => { + const invalidBaseTimelineRequest = { + ...mockBaseTimelineRequest, + iAmNotAllowed: 'butWhy?', + }; + expect(timelineRequestBasicOptionsSchema.parse(invalidBaseTimelineRequest)).toEqual( + mockBaseTimelineRequest + ); + }); + + it('should correctly error if an incorrect field type is provided for a schema key', () => { + const invalidBaseTimelineRequest = { + ...mockBaseTimelineRequest, + entityType: 'notAValidEntityType', + }; + + expect(() => { + timelineRequestBasicOptionsSchema.parse(invalidBaseTimelineRequest); + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"received\\": \\"notAValidEntityType\\", + \\"code\\": \\"invalid_enum_value\\", + \\"options\\": [ + \\"events\\", + \\"sessions\\" + ], + \\"path\\": [ + \\"entityType\\" + ], + \\"message\\": \\"Invalid enum value. Expected 'events' | 'sessions', received 'notAValidEntityType'\\" + } + ]" + `); + }); +}); diff --git a/x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.ts b/x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.ts index 5e8ea1caaa0fb..c9c3145833572 100644 --- a/x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.ts +++ b/x-pack/plugins/timelines/common/api/search_strategy/timeline/request_basic.ts @@ -12,6 +12,7 @@ import { timerange } from '../model/timerange'; export const timelineRequestBasicOptionsSchema = z.object({ indexType: z.string().optional(), + id: z.string().optional(), timerange: timerange.optional(), filterQuery, defaultIndex: z.array(z.string()).optional(), From 0763a057d349eec067bbdd7d330e57fe133daa26 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 19 Feb 2024 14:33:17 -0500 Subject: [PATCH 5/8] [8.12] [Synthetics] Simplify write access default behavior (#177088) (#177227) # Backport This will backport the following commits from `main` to `8.12`: - [[Synthetics] Simplify write access default behavior (#177088)](https://github.com/elastic/kibana/pull/177088) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Justin Kambic --- .../routes/create_route_with_auth.test.ts | 61 ++++++++++--------- .../server/routes/create_route_with_auth.ts | 19 +++--- .../routes/pings/journey_screenshot_blocks.ts | 2 +- .../routes/synthetics_service/enablement.ts | 2 +- .../synthetics_service/test_now_monitor.ts | 1 + .../plugins/synthetics/server/routes/types.ts | 5 +- 6 files changed, 48 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/synthetics/server/routes/create_route_with_auth.test.ts b/x-pack/plugins/synthetics/server/routes/create_route_with_auth.test.ts index 36262e2402b8e..30b69e0b95e4b 100644 --- a/x-pack/plugins/synthetics/server/routes/create_route_with_auth.test.ts +++ b/x-pack/plugins/synthetics/server/routes/create_route_with_auth.test.ts @@ -6,9 +6,37 @@ */ import { createSyntheticsRouteWithAuth } from './create_route_with_auth'; +import { SupportedMethod } from './types'; + +const methods: SupportedMethod[][] = [['GET'], ['POST'], ['PUT'], ['DELETE']]; describe('createSyntheticsRouteWithAuth', () => { - it('should create a route with auth', () => { + it.each( + methods + .map<[SupportedMethod, boolean]>((m) => [m[0], true]) + .concat(methods.map((m) => [m[0], false])) + )('%s methods continues to support the writeAccess %s flag', (mStr, writeAccess) => { + const method: SupportedMethod = mStr as SupportedMethod; + const route = createSyntheticsRouteWithAuth(() => ({ + method, + path: '/foo', + validate: {}, + writeAccess, + handler: async () => { + return { success: true }; + }, + })); + + expect(route).toEqual({ + method, + path: '/foo', + validate: {}, + handler: expect.any(Function), + writeAccess, + }); + }); + + it('by default allows read access for GET by default', () => { const route = createSyntheticsRouteWithAuth(() => ({ method: 'GET', path: '/foo', @@ -27,11 +55,9 @@ describe('createSyntheticsRouteWithAuth', () => { }); }); - it.each([['POST'], ['PUT'], ['DELETE']])( - 'requires write permissions for %s requests', + it.each(methods.filter((m) => m[0] !== 'GET'))( + 'by default requires write access for %s route requests', (method) => { - if (method !== 'POST' && method !== 'PUT' && method !== 'DELETE') - throw Error('Invalid method'); const route = createSyntheticsRouteWithAuth(() => ({ method, path: '/foo', @@ -50,29 +76,4 @@ describe('createSyntheticsRouteWithAuth', () => { }); } ); - - it.each([['POST'], ['PUT'], ['DELETE']])( - 'allows write access override for %s requests', - (method) => { - if (method !== 'POST' && method !== 'PUT' && method !== 'DELETE') - throw Error('Invalid method'); - const route = createSyntheticsRouteWithAuth(() => ({ - method, - path: '/foo', - validate: {}, - handler: async () => { - return { success: true }; - }, - writeAccessOverride: true, - })); - - expect(route).toEqual({ - method, - path: '/foo', - validate: {}, - handler: expect.any(Function), - writeAccess: undefined, - }); - } - ); }); diff --git a/x-pack/plugins/synthetics/server/routes/create_route_with_auth.ts b/x-pack/plugins/synthetics/server/routes/create_route_with_auth.ts index f4fb413011614..d9b6f672eb3e0 100644 --- a/x-pack/plugins/synthetics/server/routes/create_route_with_auth.ts +++ b/x-pack/plugins/synthetics/server/routes/create_route_with_auth.ts @@ -11,20 +11,23 @@ import { LICENSE_NOT_ACTIVE_ERROR, LICENSE_NOT_SUPPORTED_ERROR, } from '../../common/constants'; -import { SyntheticsRestApiRouteFactory, SyntheticsRoute, SyntheticsRouteHandler } from './types'; +import { + SupportedMethod, + SyntheticsRestApiRouteFactory, + SyntheticsRoute, + SyntheticsRouteHandler, +} from './types'; -function getWriteAccessFlag(method: string, writeAccessOverride?: boolean, writeAccess?: boolean) { - // if route includes an override, skip write-only access with `undefined` - // otherwise, if route is not a GET, require write access - // if route is get, use writeAccess value with `false` as default - return writeAccessOverride === true ? undefined : method !== 'GET' ? true : writeAccess ?? false; +function getDefaultWriteAccessFlag(method: SupportedMethod) { + // if the method is not GET, it defaults to requiring write access + return method !== 'GET'; } export const createSyntheticsRouteWithAuth = ( routeCreator: SyntheticsRestApiRouteFactory ): SyntheticsRoute => { const restRoute = routeCreator(); - const { handler, method, path, options, writeAccess, writeAccessOverride, ...rest } = restRoute; + const { handler, method, path, options, writeAccess, ...rest } = restRoute; const licenseCheckHandler: SyntheticsRouteHandler = async ({ context, response, @@ -56,7 +59,7 @@ export const createSyntheticsRouteWithAuth = ( options, handler: licenseCheckHandler, ...rest, - writeAccess: getWriteAccessFlag(method, writeAccessOverride, writeAccess), + writeAccess: writeAccess ?? getDefaultWriteAccessFlag(method), }; }; diff --git a/x-pack/plugins/synthetics/server/routes/pings/journey_screenshot_blocks.ts b/x-pack/plugins/synthetics/server/routes/pings/journey_screenshot_blocks.ts index f75a92ea555d8..8d6c62d20b7fc 100644 --- a/x-pack/plugins/synthetics/server/routes/pings/journey_screenshot_blocks.ts +++ b/x-pack/plugins/synthetics/server/routes/pings/journey_screenshot_blocks.ts @@ -22,7 +22,7 @@ export const createJourneyScreenshotBlocksRoute: SyntheticsRestApiRouteFactory = hashes: schema.arrayOf(schema.string()), }), }, - writeAccessOverride: true, + writeAccess: false, handler: async (routeProps) => { return await journeyScreenshotBlocksHandler(routeProps); }, diff --git a/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts b/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts index 5124df7af6ce0..4c29cb300d6fa 100644 --- a/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts +++ b/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts @@ -16,7 +16,7 @@ import { export const getSyntheticsEnablementRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'PUT', path: SYNTHETICS_API_URLS.SYNTHETICS_ENABLEMENT, - writeAccessOverride: true, + writeAccess: false, validate: {}, handler: async ({ savedObjectsClient, request, server }): Promise => { try { diff --git a/x-pack/plugins/synthetics/server/routes/synthetics_service/test_now_monitor.ts b/x-pack/plugins/synthetics/server/routes/synthetics_service/test_now_monitor.ts index 08c4f8dbebb3c..c2569153ce94d 100644 --- a/x-pack/plugins/synthetics/server/routes/synthetics_service/test_now_monitor.ts +++ b/x-pack/plugins/synthetics/server/routes/synthetics_service/test_now_monitor.ts @@ -27,6 +27,7 @@ export const testNowMonitorRoute: SyntheticsRestApiRouteFactory const { monitorId } = routeContext.request.params; return triggerTestNow(monitorId, routeContext); }, + writeAccess: true, }); export const triggerTestNow = async ( diff --git a/x-pack/plugins/synthetics/server/routes/types.ts b/x-pack/plugins/synthetics/server/routes/types.ts index 4d27206a16f0e..aa3f161675ae8 100644 --- a/x-pack/plugins/synthetics/server/routes/types.ts +++ b/x-pack/plugins/synthetics/server/routes/types.ts @@ -26,13 +26,14 @@ export type SyntheticsRequest = KibanaRequest< Record >; +export type SupportedMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + /** * Defines the basic properties employed by Uptime routes. */ export interface UMServerRoute { - method: 'GET' | 'PUT' | 'POST' | 'DELETE'; + method: SupportedMethod; writeAccess?: boolean; - writeAccessOverride?: boolean; handler: T; validation?: FullValidationConfig; streamHandler?: ( From 1200cf30d65459da1a86d3f21534d0477a0bde34 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:32:14 -0500 Subject: [PATCH 6/8] [8.12] [Fleet] Fix package showing 'Needs authorization' warning even after transform assets were authorized successfully (#176647) (#177236) # Backport This will backport the following commits from `main` to `8.12`: - [[Fleet] Fix package showing 'Needs authorization' warning even after transform assets were authorized successfully (#176647)](https://github.com/elastic/kibana/pull/176647) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Quynh Nguyen (Quinn) <43350163+qn895@users.noreply.github.com> --- .../epm/screens/detail/assets/assets.tsx | 13 ++++- .../assets/deferred_assets_accordion.tsx | 3 ++ .../assets/deferred_transforms_accordion.tsx | 12 ++++- .../sections/epm/screens/detail/index.tsx | 2 +- .../fleet/server/routes/epm/handlers.ts | 3 ++ .../epm/elasticsearch/transform/install.ts | 11 ++-- .../elasticsearch/transform/reauthorize.ts | 18 +++++++ .../services/epm/packages/get_bulk_assets.ts | 4 +- .../server/services/epm/packages/remove.ts | 54 ++++++++++++------- 9 files changed, 91 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx index 0b135de831c65..9c51527c4a2de 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useState, useCallback } from 'react'; import { Redirect } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiTitle, EuiCallOut } from '@elastic/eui'; @@ -38,10 +38,12 @@ import { AssetsAccordion } from './assets_accordion'; interface AssetsPanelProps { packageInfo: PackageInfo; + refetchPackageInfo: () => void; } -export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => { +export const AssetsPage = ({ packageInfo, refetchPackageInfo }: AssetsPanelProps) => { const { name, version } = packageInfo; + const pkgkey = `${name}-${version}`; const { spaces, docLinks } = useStartServices(); const customAssetsExtension = useUIExtension(packageInfo.name, 'package-detail-assets'); @@ -60,6 +62,12 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => { const [fetchError, setFetchError] = useState(); const [isLoading, setIsLoading] = useState(true); + const forceRefreshAssets = useCallback(() => { + if (refetchPackageInfo) { + refetchPackageInfo(); + } + }, [refetchPackageInfo]); + useEffect(() => { const fetchAssetSavedObjects = async () => { if ('installationInfo' in packageInfo) { @@ -245,6 +253,7 @@ export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_assets_accordion.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_assets_accordion.tsx index 4a10a360f31de..85f5984e7de64 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_assets_accordion.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_assets_accordion.tsx @@ -26,11 +26,13 @@ import { DeferredTransformAccordion } from './deferred_transforms_accordion'; interface Props { packageInfo: PackageInfo; deferredInstallations: EsAssetReference[]; + forceRefreshAssets?: () => void; } export const DeferredAssetsSection: FunctionComponent = ({ deferredInstallations, packageInfo, + forceRefreshAssets, }) => { const authz = useAuthz(); @@ -60,6 +62,7 @@ export const DeferredAssetsSection: FunctionComponent = ({ packageInfo={packageInfo} type={ElasticsearchAssetType.transform} deferredInstallations={deferredTransforms} + forceRefreshAssets={forceRefreshAssets} /> ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_transforms_accordion.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_transforms_accordion.tsx index 42b39f966e836..ed76562ed44cb 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_transforms_accordion.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/deferred_transforms_accordion.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import React, { Fragment, useCallback, useState, useMemo } from 'react'; import type { FunctionComponent, MouseEvent } from 'react'; import { @@ -42,6 +42,7 @@ interface Props { packageInfo: PackageInfo; type: ElasticsearchAssetType.transform; deferredInstallations: EsAssetReference[]; + forceRefreshAssets?: () => void; } export const getDeferredAssetDescription = ( @@ -83,6 +84,7 @@ export const DeferredTransformAccordion: FunctionComponent = ({ packageInfo, type, deferredInstallations, + forceRefreshAssets, }) => { const { notifications } = useStartServices(); const [isLoading, setIsLoading] = useState(false); @@ -159,6 +161,9 @@ export const DeferredTransformAccordion: FunctionComponent = ({ ), { toastLifeTimeMs: 1000 } ); + if (forceRefreshAssets) { + forceRefreshAssets(); + } } } } catch (e) { @@ -171,11 +176,14 @@ export const DeferredTransformAccordion: FunctionComponent = ({ } ), }); + if (forceRefreshAssets) { + forceRefreshAssets(); + } } } setIsLoading(false); }, - [notifications.toasts, packageInfo.name, packageInfo.version] + [notifications.toasts, packageInfo.name, packageInfo.version, forceRefreshAssets] ); if (deferredTransforms.length === 0) return null; return ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index b2a2ad23ba514..1312b21f49784 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -776,7 +776,7 @@ export function Detail() { - + diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 6fadeff5180c2..9080c33b8de0a 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -412,6 +412,8 @@ export const bulkInstallPackagesFromRegistryHandler: FleetRequestHandler< const savedObjectsClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const spaceId = fleetContext.spaceId; + const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, user?.username); const bulkInstalledResponses = await bulkInstallPackages({ savedObjectsClient, @@ -420,6 +422,7 @@ export const bulkInstallPackagesFromRegistryHandler: FleetRequestHandler< spaceId, prerelease: request.query.prerelease, force: request.body.force, + authorizationHeader, }); const payload = bulkInstalledResponses.map(bulkInstallServiceResponseToHttpEntry); const body: BulkInstallPackagesResponse = { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts index 984a88d87e61f..0b801710e3ebe 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts @@ -447,7 +447,6 @@ const installTransformsAssets = async ( }) : // No need to generate api key/secondary auth if all transforms are run as kibana_system user undefined; - // delete all previous transform await Promise.all([ deleteTransforms( @@ -761,7 +760,9 @@ async function handleTransformInstall({ throw err; } } - } else { + } + + if (startTransform === false || transform?.content?.settings?.unattended === true) { // if transform was not set to start automatically in yml config, // we need to check using _stats if the transform had insufficient permissions try { @@ -773,7 +774,11 @@ async function handleTransformInstall({ ), { logger, additionalResponseStatuses: [400] } ); - if (Array.isArray(transformStats.transforms) && transformStats.transforms.length === 1) { + if ( + transformStats && + Array.isArray(transformStats.transforms) && + transformStats.transforms.length === 1 + ) { const transformHealth = transformStats.transforms[0].health; if ( transformHealth && diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/reauthorize.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/reauthorize.ts index 7bba68d84bcf8..b3d4d388811b2 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/reauthorize.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/reauthorize.ts @@ -10,6 +10,8 @@ import type { Logger } from '@kbn/logging'; import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { sortBy, uniqBy } from 'lodash'; +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import type { ErrorResponseBase } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { SecondaryAuthorizationHeader } from '../../../../../common/types/models/transform_api_key'; import { updateEsAssetReferences } from '../../packages/install'; @@ -30,6 +32,9 @@ interface FleetTransformMetadata { transformId: string; } +const isErrorResponse = (arg: unknown): arg is ErrorResponseBase => + isPopulatedObject(arg, ['error']); + async function reauthorizeAndStartTransform({ esClient, logger, @@ -68,6 +73,19 @@ async function reauthorizeAndStartTransform({ () => esClient.transform.startTransform({ transform_id: transformId }, { ignore: [409] }), { logger, additionalResponseStatuses: [400] } ); + + // Transform can already be started even without sufficient permission if 'unattended: true' + // So we are just catching that special case to showcase in the UI + // If unattended, calling _start will return a successful response, but with the error message in the body + if ( + isErrorResponse(startedTransform) && + startedTransform.status === 409 && + Array.isArray(startedTransform.error?.root_cause) && + startedTransform.error.root_cause[0]?.reason?.includes('already started') + ) { + return { transformId, success: true, error: null }; + } + logger.debug(`Started transform: ${transformId}`); return { transformId, success: startedTransform.acknowledged, error: null }; } catch (err) { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_bulk_assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_bulk_assets.ts index ebeaed684c3a0..8e66dc904dbf2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_bulk_assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_bulk_assets.ts @@ -37,8 +37,8 @@ export async function getBulkAssets( type: obj.type as unknown as ElasticsearchAssetType | KibanaSavedObjectType, updatedAt: obj.updated_at, attributes: { - title: obj.attributes.title, - description: obj.attributes.description, + title: obj.attributes?.title, + description: obj.attributes?.description, }, }; }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index ba9bace6a0dee..436c2efaa2275 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -190,24 +190,35 @@ async function deleteAssets( // must delete index templates first, or component templates which reference them cannot be deleted // must delete ingestPipelines first, or ml models referenced in them cannot be deleted. // separate the assets into Index Templates and other assets. - type Tuple = [EsAssetReference[], EsAssetReference[], EsAssetReference[]]; - const [indexTemplatesAndPipelines, indexAssets, otherAssets] = installedEs.reduce( - ([indexTemplateAndPipelineTypes, indexAssetTypes, otherAssetTypes], asset) => { - if ( - asset.type === ElasticsearchAssetType.indexTemplate || - asset.type === ElasticsearchAssetType.ingestPipeline - ) { - indexTemplateAndPipelineTypes.push(asset); - } else if (asset.type === ElasticsearchAssetType.index) { - indexAssetTypes.push(asset); - } else { - otherAssetTypes.push(asset); - } - - return [indexTemplateAndPipelineTypes, indexAssetTypes, otherAssetTypes]; - }, - [[], [], []] - ); + type Tuple = [EsAssetReference[], EsAssetReference[], EsAssetReference[], EsAssetReference[]]; + const [indexTemplatesAndPipelines, indexAssets, transformAssets, otherAssets] = + installedEs.reduce( + ( + [indexTemplateAndPipelineTypes, indexAssetTypes, transformAssetTypes, otherAssetTypes], + asset + ) => { + if ( + asset.type === ElasticsearchAssetType.indexTemplate || + asset.type === ElasticsearchAssetType.ingestPipeline + ) { + indexTemplateAndPipelineTypes.push(asset); + } else if (asset.type === ElasticsearchAssetType.index) { + indexAssetTypes.push(asset); + } else if (asset.type === ElasticsearchAssetType.transform) { + transformAssetTypes.push(asset); + } else { + otherAssetTypes.push(asset); + } + + return [ + indexTemplateAndPipelineTypes, + indexAssetTypes, + transformAssetTypes, + otherAssetTypes, + ]; + }, + [[], [], [], []] + ); try { // must first unset any default pipeline associated with any existing indices @@ -215,7 +226,12 @@ async function deleteAssets( await Promise.all( indexAssets.map((asset) => updateIndexSettings(esClient, asset.id, { default_pipeline: '' })) ); - // must delete index templates and pipelines first + + // in case transform's destination index contains any pipline, + // we should delete the transforms first + await Promise.all(deleteESAssets(transformAssets, esClient)); + + // then delete index templates and pipelines await Promise.all(deleteESAssets(indexTemplatesAndPipelines, esClient)); // then the other asset types await Promise.all([ From 1b457956edbaddc01f7572934e23df057a09f852 Mon Sep 17 00:00:00 2001 From: Georgii Gorbachev Date: Tue, 20 Feb 2024 12:18:18 +0100 Subject: [PATCH 7/8] [8.12] [Security Solution] Fix rule filters on the Rule Details page (#177081) (#177260) # Backport This will backport the following commits from `main` to `8.12`: - [[Security Solution] Fix rule filters on the Rule Details page (#177081)](https://github.com/elastic/kibana/pull/177081) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) --- .../rule_definition_section.styles.ts | 45 +++++++ .../rule_details/rule_definition_section.tsx | 125 ++++++++---------- 2 files changed, 101 insertions(+), 69 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.styles.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.styles.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.styles.ts new file mode 100644 index 0000000000000..30439722cd18b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.styles.ts @@ -0,0 +1,45 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; +import { useMemo } from 'react'; + +export const useFiltersStyles = () => { + return useMemo( + () => ({ + flexGroup: css` + max-width: 600px; + `, + }), + [] + ); +}; + +export const useQueryStyles = () => { + return useMemo( + () => ({ + content: css` + white-space: pre-wrap; + `, + }), + [] + ); +}; + +export const useRequiredFieldsStyles = () => { + const { euiTheme } = useEuiTheme(); + return useMemo( + () => ({ + fieldTypeText: css({ + fontFamily: euiTheme.font.familyCode, + display: 'inline', + }), + }), + [euiTheme.font.familyCode] + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx index 7815afa72c959..ec8a557459c26 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { isEmpty } from 'lodash/fp'; -import styled from 'styled-components'; import { EuiDescriptionList, EuiText, @@ -15,7 +14,6 @@ import { EuiFlexItem, EuiFlexGroup, EuiLoadingSpinner, - EuiBadge, } from '@elastic/eui'; import type { EuiDescriptionListProps } from '@elastic/eui'; import type { @@ -25,9 +23,10 @@ import type { import type { Filter } from '@kbn/es-query'; import type { SavedQuery } from '@kbn/data-plugin/public'; import { mapAndFlattenFilters } from '@kbn/data-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; import { FieldIcon } from '@kbn/react-field'; import { castEsToKbnFieldTypeName } from '@kbn/field-types'; -import { FilterBadgeGroup } from '@kbn/unified-search-plugin/public'; +import { FilterItems } from '@kbn/unified-search-plugin/public'; import type { AlertSuppressionMissingFieldsStrategy, RequiredFieldArray, @@ -55,6 +54,11 @@ import { BadgeList } from './badge_list'; import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; import * as i18n from './translations'; import type { ExperimentalFeatures } from '../../../../../common/experimental_features'; +import { + useFiltersStyles, + useQueryStyles, + useRequiredFieldsStyles, +} from './rule_definition_section.styles'; interface SavedQueryNameProps { savedQueryName: string; @@ -66,12 +70,6 @@ const SavedQueryName = ({ savedQueryName }: SavedQueryNameProps) => ( ); -const EuiBadgeWrap = styled(EuiBadge)` - .euiBadge__text { - white-space: pre-wrap !important; - } -`; - interface FiltersProps { filters: Filter[]; dataViewId?: string; @@ -80,51 +78,42 @@ interface FiltersProps { } const Filters = ({ filters, dataViewId, index, 'data-test-subj': dataTestSubj }: FiltersProps) => { + const flattenedFilters = mapAndFlattenFilters(filters); + const { indexPattern } = useRuleIndexPattern({ dataSourceType: dataViewId ? DataSourceType.DataView : DataSourceType.IndexPatterns, index: index ?? [], dataViewId, }); - const flattenedFilters = mapAndFlattenFilters(filters); + const styles = useFiltersStyles(); return ( - - {flattenedFilters.map((filter, idx) => { - const displayContent = filter.meta.alias ? ( - filter.meta.alias - ) : ( - - ); - return ( - - - {indexPattern != null ? displayContent : } - - - ); - })} + + ); }; -const QueryContent = styled.div` - white-space: pre-wrap; -`; - interface QueryProps { query: string; 'data-test-subj'?: string; } -const Query = ({ query, 'data-test-subj': dataTestSubj = 'query' }: QueryProps) => ( - {query} -); +const Query = ({ query, 'data-test-subj': dataTestSubj = 'query' }: QueryProps) => { + const styles = useQueryStyles(); + return ( +
+ {query} +
+ ); +}; interface IndexProps { index: string[]; @@ -260,42 +249,40 @@ const RuleType = ({ type }: RuleTypeProps) => ( {getRuleTypeDescription(type)} ); -const StyledFieldTypeText = styled(EuiText)` - font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; - font-family: ${({ theme }) => theme.eui.euiCodeFontFamily}; - display: inline; -`; - interface RequiredFieldsProps { requiredFields: RequiredFieldArray; } -const RequiredFields = ({ requiredFields }: RequiredFieldsProps) => ( - - {requiredFields.map((rF, index) => ( - - - - - - - - {` ${rF.name}${index + 1 !== requiredFields.length ? ', ' : ''}`} - - - - - ))} - -); +const RequiredFields = ({ requiredFields }: RequiredFieldsProps) => { + const styles = useRequiredFieldsStyles(); + return ( + + {requiredFields.map((rF, index) => ( + + + + + + + + {` ${rF.name}${index + 1 !== requiredFields.length ? ', ' : ''}`} + + + + + ))} + + ); +}; interface TimelineTitleProps { timelineTitle: string; From 6536da7fb164da171bee98bbb5fd849821b2773c Mon Sep 17 00:00:00 2001 From: Cristina Amico Date: Tue, 20 Feb 2024 15:31:26 +0100 Subject: [PATCH 8/8] [8.12] [Fleet] Fix inactive popover tour not resetting (#176929) (#177172) # Backport This will backport the following commits from `main` to `8.12`: - [[Fleet] Fix inactive popover tour not resetting (#176929)](https://github.com/elastic/kibana/pull/176929) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) --- .../components/agent_status_filter.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx index ece89842c1bf6..9152b04970ba6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx @@ -13,6 +13,7 @@ import { EuiText, EuiTourStep, useEuiTheme, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -64,17 +65,11 @@ const LeftpaddedNotificationBadge = styled(EuiNotificationBadge)` margin-left: 10px; `; -const TourStepNoHeaderFooter = styled(EuiTourStep)` - .euiTourFooter { - display: none; - } - .euiTourHeader { - display: none; - } -`; - -const InactiveAgentsTourStep: React.FC<{ isOpen: boolean }> = ({ children, isOpen }) => ( - void; +}> = ({ children, isOpen, setInactiveAgentsCalloutHasBeenDismissed }) => ( + = ({ children, isOpe onFinish={() => {}} anchorPosition="upCenter" maxWidth={280} + footerAction={ + { + setInactiveAgentsCalloutHasBeenDismissed(true); + }} + > + + + } > {children as React.ReactElement} - + ); export const AgentStatusFilter: React.FC<{ @@ -160,6 +167,7 @@ export const AgentStatusFilter: React.FC<{ return ( 0 && !inactiveAgentsCalloutHasBeenDismissed} + setInactiveAgentsCalloutHasBeenDismissed={setInactiveAgentsCalloutHasBeenDismissed} >