From 509f9ed4fe7e598e809eeebfe804755043fb0c89 Mon Sep 17 00:00:00 2001 From: Rickyanto Ang Date: Mon, 12 Feb 2024 13:43:13 -0800 Subject: [PATCH] [Cloud Security] Added Toast message that pops up when user enables / disables rule (#176462) ## Summary This PR adds a toast message that pops up whenever user enables or disables rules. The toast message will show the number of rules that got disabled/enable and detection rules (if there's any) https://github.com/elastic/kibana/assets/8703149/6b394fc4-82ca-4afa-a6b4-e504e8a83f0c --- .../common/utils/detection_rules.test.ts | 59 +++++++++++++++++-- .../common/utils/detection_rules.ts | 33 ++++++++++- .../api/use_fetch_detection_rules_by_tags.ts | 39 ++++++++---- .../public/components/take_action.tsx | 44 ++++++++++++-- .../public/pages/rules/rules_flyout.tsx | 18 +++++- .../public/pages/rules/rules_table.tsx | 33 +++++++++-- .../public/pages/rules/rules_table_header.tsx | 15 +++++ .../benchmark_rules/bulk_action/utils.ts | 4 +- 8 files changed, 212 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts index f2a35944f0825..a067ef4e1871a 100644 --- a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts +++ b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts @@ -7,25 +7,45 @@ import { CspBenchmarkRuleMetadata } from '../types'; import { - convertRuleTagsToKQL, + convertRuleTagsToMatchAllKQL, + convertRuleTagsToMatchAnyKQL, generateBenchmarkRuleTags, getFindingsDetectionRuleSearchTags, + getFindingsDetectionRuleSearchTagsFromArrayOfRules, } from './detection_rules'; describe('Detection rules utils', () => { - it('should convert tags to KQL format', () => { + it('should convert tags to KQL format with AND operator', () => { const inputTags = ['tag1', 'tag2', 'tag3']; - const result = convertRuleTagsToKQL(inputTags); + const result = convertRuleTagsToMatchAllKQL(inputTags); const expectedKQL = 'alert.attributes.tags:("tag1" AND "tag2" AND "tag3")'; expect(result).toBe(expectedKQL); }); - it('Should convert tags to KQL format', () => { + it('Should convert tags to KQL format with AND Operator (empty array)', () => { const inputTags = [] as string[]; - const result = convertRuleTagsToKQL(inputTags); + const result = convertRuleTagsToMatchAllKQL(inputTags); + + const expectedKQL = 'alert.attributes.tags:()'; + expect(result).toBe(expectedKQL); + }); + + it('should convert tags to KQL format with OR Operator', () => { + const inputTags = ['tag1', 'tag2', 'tag3']; + + const result = convertRuleTagsToMatchAnyKQL(inputTags); + + const expectedKQL = 'alert.attributes.tags:("tag1" OR "tag2" OR "tag3")'; + expect(result).toBe(expectedKQL); + }); + + it('Should convert tags to KQL format with OR Operator (empty array)', () => { + const inputTags = [] as string[]; + + const result = convertRuleTagsToMatchAnyKQL(inputTags); const expectedKQL = 'alert.attributes.tags:()'; expect(result).toBe(expectedKQL); @@ -63,6 +83,35 @@ describe('Detection rules utils', () => { expect(result).toEqual(expectedTags); }); + it('Should generate search tags for a CSP benchmark rule given an array of Benchmarks', () => { + const cspBenchmarkRule = [ + { + benchmark: { + id: 'cis_gcp', + rule_number: '1.1', + }, + }, + { + benchmark: { + id: 'cis_gcp', + rule_number: '1.2', + }, + }, + ] as unknown as CspBenchmarkRuleMetadata[]; + + const result = getFindingsDetectionRuleSearchTagsFromArrayOfRules(cspBenchmarkRule); + + const expectedTags = ['CIS GCP 1.1', 'CIS GCP 1.2']; + expect(result).toEqual(expectedTags); + }); + + it('Should handle undefined benchmark object gracefully given an array of empty benchmark', () => { + const cspBenchmarkRule = [{ benchmark: {} }] as any; + const expectedTags: string[] = []; + const result = getFindingsDetectionRuleSearchTagsFromArrayOfRules(cspBenchmarkRule); + expect(result).toEqual(expectedTags); + }); + it('Should generate tags for a CSPM benchmark rule', () => { const cspBenchmarkRule = { benchmark: { diff --git a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts index 42ea7561286c1..f178d412675e6 100644 --- a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts +++ b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts @@ -13,9 +13,14 @@ const CSP_RULE_TAG_DATA_SOURCE_PREFIX = 'Data Source: '; const STATIC_RULE_TAGS = [CSP_RULE_TAG, CSP_RULE_TAG_USE_CASE]; -export const convertRuleTagsToKQL = (tags: string[]): string => { +export const convertRuleTagsToMatchAllKQL = (tags: string[]): string => { const TAGS_FIELD = 'alert.attributes.tags'; - return `${TAGS_FIELD}:(${tags.map((tag) => `"${tag}"`).join(' AND ')})`; + return `${TAGS_FIELD}:(${tags.map((tag) => `"${tag}"`).join(` AND `)})`; +}; + +export const convertRuleTagsToMatchAnyKQL = (tags: string[]): string => { + const TAGS_FIELD = 'alert.attributes.tags'; + return `${TAGS_FIELD}:(${tags.map((tag) => `"${tag}"`).join(` OR `)})`; }; /* @@ -42,6 +47,30 @@ export const getFindingsDetectionRuleSearchTags = ( return benchmarkIdTags.concat([benchmarkRuleNumberTag]); }; +export const getFindingsDetectionRuleSearchTagsFromArrayOfRules = ( + cspBenchmarkRules: CspBenchmarkRuleMetadata[] +): string[] => { + if ( + !cspBenchmarkRules || + !cspBenchmarkRules.some((rule) => rule.benchmark) || + !cspBenchmarkRules.some((rule) => rule.benchmark.id) + ) { + return []; + } + + // we can just take the first benchmark id because we Know that the array will ONLY contain 1 kind of id + const benchmarkIds = cspBenchmarkRules.map((rule) => rule.benchmark.id); + if (benchmarkIds.length === 0) return []; + const benchmarkId = benchmarkIds[0]; + const benchmarkRuleNumbers = cspBenchmarkRules.map((rule) => rule.benchmark.rule_number); + if (benchmarkRuleNumbers.length === 0) return []; + const benchmarkTagArray = benchmarkRuleNumbers.map( + (tag) => benchmarkId.replace('_', ' ').toUpperCase() + ' ' + tag + ); + // we want the tags to only consist of a format like this CIS AWS 1.1.0 + return benchmarkTagArray; +}; + export const generateBenchmarkRuleTags = (rule: CspBenchmarkRuleMetadata) => { return [STATIC_RULE_TAGS] .concat(getFindingsDetectionRuleSearchTags(rule)) diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts index dfd6f13e38692..da95711cbb383 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts @@ -5,13 +5,16 @@ * 2.0. */ -import { CoreStart } from '@kbn/core/public'; +import { CoreStart, HttpSetup } from '@kbn/core/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useQuery } from '@tanstack/react-query'; import { DETECTION_RULE_RULES_API_CURRENT_VERSION } from '../../../common/constants'; import { RuleResponse } from '../types'; import { DETECTION_ENGINE_RULES_KEY } from '../constants'; -import { convertRuleTagsToKQL } from '../../../common/utils/detection_rules'; +import { + convertRuleTagsToMatchAllKQL, + convertRuleTagsToMatchAnyKQL, +} from '../../../common/utils/detection_rules'; /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one @@ -31,20 +34,32 @@ const DETECTION_ENGINE_URL = '/api/detection_engine' as const; const DETECTION_ENGINE_RULES_URL = `${DETECTION_ENGINE_URL}/rules` as const; export const DETECTION_ENGINE_RULES_URL_FIND = `${DETECTION_ENGINE_RULES_URL}/_find` as const; -export const useFetchDetectionRulesByTags = (tags: string[]) => { +export const useFetchDetectionRulesByTags = ( + tags: string[], + option: { match: 'all' | 'any' } = { match: 'all' } +) => { const { http } = useKibana().services; + return useQuery([DETECTION_ENGINE_RULES_KEY, tags, option], () => + fetchDetectionRulesByTags(tags, option, http) + ); +}; +export const fetchDetectionRulesByTags = ( + tags: string[], + option: { match: 'all' | 'any' } = { match: 'all' }, + http: HttpSetup +) => { const query = { page: 1, per_page: 1, - filter: convertRuleTagsToKQL(tags), + filter: + option.match === 'all' + ? convertRuleTagsToMatchAllKQL(tags) + : convertRuleTagsToMatchAnyKQL(tags), }; - - return useQuery([DETECTION_ENGINE_RULES_KEY, tags], () => - http.fetch(DETECTION_ENGINE_RULES_URL_FIND, { - method: 'GET', - version: DETECTION_RULE_RULES_API_CURRENT_VERSION, - query, - }) - ); + return http.fetch(DETECTION_ENGINE_RULES_URL_FIND, { + method: 'GET', + version: DETECTION_RULE_RULES_API_CURRENT_VERSION, + query, + }); }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx b/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx index caf7f34651997..054fc9f3759ff 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx @@ -80,7 +80,11 @@ export const showCreateDetectionRuleSuccessToast = ( export const showChangeBenchmarkRuleStatesSuccessToast = ( notifications: NotificationsStart, - isBenchmarkRuleMuted: boolean + isBenchmarkRuleMuted: boolean, + data: { + numberOfRules: number; + numberOfDetectionRules: number; + } ) => { return notifications.toasts.addSuccess({ toastLifeTimeMs: 10000, @@ -101,9 +105,23 @@ export const showChangeBenchmarkRuleStatesSuccessToast = ( + {data.numberOfDetectionRules > 0 ? ( + + + + ) : undefined} ) : ( <> @@ -115,10 +133,26 @@ export const showChangeBenchmarkRuleStatesSuccessToast = ( /> + + + {data.numberOfDetectionRules > 0 ? ( + + + + ) : undefined} )} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx index 333f958de6fb1..5026884173b6e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx @@ -21,15 +21,20 @@ import { EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; - import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '../../common/hooks/use_kibana'; +import { getFindingsDetectionRuleSearchTags } from '../../../common/utils/detection_rules'; import { CspBenchmarkRuleMetadata } from '../../../common/types/latest'; import { getRuleList } from '../configurations/findings_flyout/rule_tab'; import { getRemediationList } from '../configurations/findings_flyout/overview_tab'; import * as TEST_SUBJECTS from './test_subjects'; import { useChangeCspRuleState } from './change_csp_rule_state'; import { CspBenchmarkRulesWithStates } from './rules_container'; -import { TakeAction } from '../../components/take_action'; +import { + showChangeBenchmarkRuleStatesSuccessToast, + TakeAction, +} from '../../components/take_action'; +import { useFetchDetectionRulesByTags } from '../../common/api/use_fetch_detection_rules_by_tags'; export const RULES_FLYOUT_SWITCH_BUTTON = 'rule-flyout-switch-button'; @@ -61,8 +66,11 @@ type RuleTab = typeof tabs[number]['id']; export const RuleFlyout = ({ onClose, rule, refetchRulesStates }: RuleFlyoutProps) => { const [tab, setTab] = useState('overview'); const postRequestChangeRulesStates = useChangeCspRuleState(); + const { data: rulesData } = useFetchDetectionRulesByTags( + getFindingsDetectionRuleSearchTags(rule.metadata) + ); const isRuleMuted = rule?.state === 'muted'; - + const { notifications } = useKibana().services; const switchRuleStates = async () => { if (rule.metadata.benchmark.rule_number) { const rulesObjectRequest = { @@ -74,6 +82,10 @@ export const RuleFlyout = ({ onClose, rule, refetchRulesStates }: RuleFlyoutProp const nextRuleStates = isRuleMuted ? 'unmute' : 'mute'; await postRequestChangeRulesStates(nextRuleStates, [rulesObjectRequest]); await refetchRulesStates(); + await showChangeBenchmarkRuleStatesSuccessToast(notifications, isRuleMuted, { + numberOfRules: 1, + numberOfDetectionRules: rulesData?.total || 0, + }); } }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx index 81a04de69f174..586f0b9dee0cf 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx @@ -20,10 +20,15 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { uniqBy } from 'lodash'; +import { CoreStart, HttpSetup, NotificationsStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { getFindingsDetectionRuleSearchTags } from '../../../common/utils/detection_rules'; import { ColumnNameWithTooltip } from '../../components/column_name_with_tooltip'; import type { CspBenchmarkRulesWithStates, RulesState } from './rules_container'; import * as TEST_SUBJECTS from './test_subjects'; import { RuleStateAttributesWithoutStates, useChangeCspRuleState } from './change_csp_rule_state'; +import { showChangeBenchmarkRuleStatesSuccessToast } from '../../components/take_action'; +import { fetchDetectionRulesByTags } from '../../common/api/use_fetch_detection_rules_by_tags'; export const RULES_ROWS_ENABLE_SWITCH_BUTTON = 'rules-row-enable-switch-button'; export const RULES_ROW_SELECT_ALL_CURRENT_PAGE = 'cloud-security-fields-selector-item-all'; @@ -56,6 +61,8 @@ type GetColumnProps = Pick< currentPageRulesArray: CspBenchmarkRulesWithStates[], selectedRulesArray: CspBenchmarkRulesWithStates[] ) => boolean; + notifications: NotificationsStart; + http: HttpSetup; }; export const RulesTable = ({ @@ -87,7 +94,6 @@ export const RulesTable = ({ direction: sortDirection, }, }; - const onTableChange = ({ page: pagination, sort: sortOrder, @@ -108,6 +114,7 @@ export const RulesTable = ({ }); const [isAllRulesSelectedThisPage, setIsAllRulesSelectedThisPage] = useState(false); + const postRequestChangeRulesStates = useChangeCspRuleState(); const isCurrentPageRulesASubset = ( @@ -125,6 +132,7 @@ export const RulesTable = ({ return true; }; + const { http, notifications } = useKibana().services; useEffect(() => { if (selectedRules.length >= items.length && items.length > 0 && selectedRules.length > 0) setIsAllRulesSelectedThisPage(true); @@ -143,6 +151,8 @@ export const RulesTable = ({ isAllRulesSelectedThisPage, isCurrentPageRulesASubset, onRuleClick, + notifications, + http, }), [ refetchRulesStates, @@ -152,6 +162,8 @@ export const RulesTable = ({ items, isAllRulesSelectedThisPage, onRuleClick, + notifications, + http, ] ); @@ -182,6 +194,8 @@ const getColumns = ({ isAllRulesSelectedThisPage, isCurrentPageRulesASubset, onRuleClick, + notifications, + http, }: GetColumnProps): Array> => [ { field: 'action', @@ -296,11 +310,22 @@ const getColumns = ({ }; const isRuleMuted = rule?.state === 'muted'; const nextRuleState = isRuleMuted ? 'unmute' : 'mute'; - - const useChangeCspRuleStateFn = async () => { + const changeCspRuleStateFn = async () => { if (rule?.metadata.benchmark.rule_number) { + // Calling this function this way to make sure it didn't get called on every single row render, its only being called when user click on the switch button + const detectionRuleCount = ( + await fetchDetectionRulesByTags( + getFindingsDetectionRuleSearchTags(rule.metadata), + { match: 'all' }, + http + ) + ).total; await postRequestChangeRulesStates(nextRuleState, [rulesObjectRequest]); await refetchRulesStates(); + await showChangeBenchmarkRuleStatesSuccessToast(notifications, isRuleMuted, { + numberOfRules: 1, + numberOfDetectionRules: detectionRuleCount || 0, + }); } }; return ( @@ -309,7 +334,7 @@ const getColumns = ({ !e); }; + const { data: rulesData } = useFetchDetectionRulesByTags( + getFindingsDetectionRuleSearchTagsFromArrayOfRules(selectedRules.map((rule) => rule.metadata)), + { match: 'any' } + ); + + const { notifications } = useKibana().services; + const postRequestChangeRulesState = useChangeCspRuleState(); const changeRulesState = async (state: 'mute' | 'unmute') => { const bulkSelectedRules: RuleStateAttributesWithoutStates[] = selectedRules.map( @@ -283,6 +294,10 @@ const CurrentPageOfTotal = ({ await postRequestChangeRulesState(state, bulkSelectedRules); await refetchRulesStates(); await setIsPopoverOpen(false); + await showChangeBenchmarkRuleStatesSuccessToast(notifications, state !== 'mute', { + numberOfRules: bulkSelectedRules.length, + numberOfDetectionRules: rulesData?.total || 0, + }); } }; const changeCspRuleStateMute = async () => { diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts index 9efb4267a385d..5b592b6b9926c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts @@ -15,7 +15,7 @@ import type { CspSettings, } from '../../../../common/types/rules/v4'; import { - convertRuleTagsToKQL, + convertRuleTagsToMatchAllKQL, generateBenchmarkRuleTags, } from '../../../../common/utils/detection_rules'; @@ -55,7 +55,7 @@ export const getDetectionRules = async ( return detectionRulesClient.find({ excludeFromPublicApi: false, options: { - filter: convertRuleTagsToKQL(ruleTags), + filter: convertRuleTagsToMatchAllKQL(ruleTags), searchFields: ['tags'], page: 1, per_page: 1,