diff --git a/x-pack/solutions/security/packages/data-table/common/types/data_table/index.ts b/x-pack/solutions/security/packages/data-table/common/types/data_table/index.ts index f024e83ae702e..f94a428323b65 100644 --- a/x-pack/solutions/security/packages/data-table/common/types/data_table/index.ts +++ b/x-pack/solutions/security/packages/data-table/common/types/data_table/index.ts @@ -11,6 +11,7 @@ import * as runtimeTypes from 'io-ts'; export { Direction }; export type SortDirectionTable = 'none' | 'asc' | 'desc' | Direction; + export interface SortColumnTable { columnId: string; columnType: string; @@ -25,6 +26,7 @@ export enum TableId { hostsPageSessions = 'hosts-page-sessions-v2', // the v2 is to cache bust localstorage settings as default columns were reworked. alertsOnRuleDetailsPage = 'alerts-rules-details-page', alertsOnAlertsPage = 'alerts-page', + alertsOnAlertSummaryPage = 'alert-summary-page', test = 'table-test', // Reserved for testing purposes alternateTest = 'alternateTest', rulePreview = 'rule-preview', @@ -43,6 +45,7 @@ export enum TableEntityType { export const tableEntity: Record = { [TableId.alertsOnAlertsPage]: TableEntityType.alert, + [TableId.alertsOnAlertSummaryPage]: TableEntityType.alert, [TableId.alertsOnCasePage]: TableEntityType.alert, [TableId.alertsOnRuleDetailsPage]: TableEntityType.alert, [TableId.hostsPageEvents]: TableEntityType.event, @@ -64,6 +67,7 @@ const TableIdLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TableId.hostsPageSessions), runtimeTypes.literal(TableId.alertsOnRuleDetailsPage), runtimeTypes.literal(TableId.alertsOnAlertsPage), + runtimeTypes.literal(TableId.alertsOnAlertSummaryPage), runtimeTypes.literal(TableId.test), runtimeTypes.literal(TableId.rulePreview), runtimeTypes.literal(TableId.kubernetesPageSessions), diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/additional_toolbar_controls.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/additional_toolbar_controls.test.tsx new file mode 100644 index 0000000000000..19be977584d59 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/additional_toolbar_controls.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 React from 'react'; +import { AdditionalToolbarControls } from './additional_toolbar_controls'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); +jest.mock('../../../../common/hooks/use_selector'); + +const dataView: DataView = createStubDataView({ spec: {} }); +const mockOptions = [ + { label: 'ruleName', key: 'kibana.alert.rule.name' }, + { label: 'userName', key: 'user.name' }, + { label: 'hostName', key: 'host.name' }, + { label: 'sourceIP', key: 'source.ip' }, +]; +const tableId = TableId.alertsOnAlertSummaryPage; + +const groups = { + [tableId]: { options: mockOptions, activeGroups: ['kibana.alert.rule.name'] }, +}; + +describe('AdditionalToolbarControls', () => { + beforeEach(() => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => groups[tableId]); + }); + + test('should render the group selector component and allow the user to select a grouping field', () => { + const store = createMockStore({ + ...mockGlobalState, + groups, + }); + render( + + + + ); + + fireEvent.click(screen.getByTestId('group-selector-dropdown')); + fireEvent.click(screen.getByTestId('panel-user.name')); + expect(mockDispatch.mock.calls[0][0].payload).toEqual({ + activeGroups: ['user.name'], + tableId, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/additional_toolbar_controls.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/additional_toolbar_controls.tsx new file mode 100644 index 0000000000000..5ee977071c14e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/additional_toolbar_controls.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { useGetGroupSelectorStateless } from '@kbn/grouping/src/hooks/use_get_group_selector'; +import { useDispatch } from 'react-redux'; +import { groupIdSelector } from '../../../../common/store/grouping/selectors'; +import { updateGroups } from '../../../../common/store/grouping/actions'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; + +const TABLE_ID = TableId.alertsOnAlertSummaryPage; +const MAX_GROUPING_LEVELS = 3; +const NO_OPTIONS = { options: [] }; + +export interface RenderAdditionalToolbarControlsProps { + /** + * DataView created for the alert summary page + */ + dataView: DataView; +} + +/** + * Renders a button that when clicked shows a dropdown to allow selecting a group for the GroupedAlertTable. + * Handles further communication with the kbn-grouping package via redux. + */ +export const AdditionalToolbarControls = memo( + ({ dataView }: RenderAdditionalToolbarControlsProps) => { + const dispatch = useDispatch(); + + const onGroupChange = useCallback( + (selectedGroups: string[]) => + dispatch(updateGroups({ activeGroups: selectedGroups, tableId: TABLE_ID })), + [dispatch] + ); + + const groupId = useMemo(() => groupIdSelector(), []); + const { options: defaultGroupingOptions } = + useDeepEqualSelector((state) => groupId(state, TABLE_ID)) ?? NO_OPTIONS; + + const groupSelector = useGetGroupSelectorStateless({ + groupingId: TABLE_ID, + onGroupChange, + fields: dataView.fields, + defaultGroupingOptions, + maxGroupingLevels: MAX_GROUPING_LEVELS, + }); + + return <>{groupSelector}; + } +); + +AdditionalToolbarControls.displayName = 'AdditionalToolbarControls'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_aggregations.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_aggregations.test.tsx new file mode 100644 index 0000000000000..98afbabedb2bd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_aggregations.test.tsx @@ -0,0 +1,125 @@ +/* + * 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 { groupStatsAggregations } from './group_stats_aggregations'; + +describe('groupStatsAggregations', () => { + it('should return values depending for signal.rule.id input field', () => { + const aggregations = groupStatsAggregations('signal.rule.id'); + expect(aggregations).toEqual([ + { + unitsCount: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + { + severitiesSubAggregation: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + { + rulesCountAggregation: { + cardinality: { + field: 'kibana.alert.rule.rule_id', + }, + }, + }, + ]); + }); + + it('should return values depending for kibana.alert.severity input field', () => { + const aggregations = groupStatsAggregations('kibana.alert.severity'); + expect(aggregations).toEqual([ + { + unitsCount: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + { + signalRuleIdSubAggregation: { + terms: { + field: 'signal.rule.id', + }, + }, + }, + { + rulesCountAggregation: { + cardinality: { + field: 'kibana.alert.rule.rule_id', + }, + }, + }, + ]); + }); + + it('should return values depending for kibana.alert.rule.name input field', () => { + const aggregations = groupStatsAggregations('kibana.alert.rule.name'); + expect(aggregations).toEqual([ + { + unitsCount: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + { + signalRuleIdSubAggregation: { + terms: { + field: 'signal.rule.id', + }, + }, + }, + { + severitiesSubAggregation: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + ]); + }); + + it('should return the default values if the field is not supported', () => { + const aggregations = groupStatsAggregations('unknown'); + expect(aggregations).toEqual([ + { + unitsCount: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + { + signalRuleIdSubAggregation: { + terms: { + field: 'signal.rule.id', + }, + }, + }, + { + severitiesSubAggregation: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + { + rulesCountAggregation: { + cardinality: { + field: 'kibana.alert.rule.rule_id', + }, + }, + }, + ]); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_aggregations.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_aggregations.ts new file mode 100644 index 0000000000000..917b7f2396058 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_aggregations.ts @@ -0,0 +1,54 @@ +/* + * 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 { NamedAggregation } from '@kbn/grouping'; +import { DEFAULT_GROUP_STATS_AGGREGATION } from '../../alerts_table/alerts_grouping'; +import { + RULE_COUNT_AGGREGATION, + SEVERITY_SUB_AGGREGATION, +} from '../../alerts_table/grouping_settings'; + +const RULE_SIGNAL_ID_SUB_AGGREGATION = { + signalRuleIdSubAggregation: { + terms: { + field: 'signal.rule.id', + }, + }, +}; + +/** + * Returns aggregations to be used to calculate the statistics to be used in the`extraAction` property of the EuiAccordion component. + * It handles custom renders for the following fields: + * - signal.rule.id + * - kibana.alert.severity + * - kibana.alert.rule.name + * And returns a default set of aggregation for all the other fields. + * + * These go hand in hand with groupingOptions and groupPanelRenderers. + */ +export const groupStatsAggregations = (field: string): NamedAggregation[] => { + const aggMetrics: NamedAggregation[] = DEFAULT_GROUP_STATS_AGGREGATION(''); + + switch (field) { + case 'signal.rule.id': + aggMetrics.push(SEVERITY_SUB_AGGREGATION, RULE_COUNT_AGGREGATION); + break; + case 'kibana.alert.severity': + aggMetrics.push(RULE_SIGNAL_ID_SUB_AGGREGATION, RULE_COUNT_AGGREGATION); + break; + case 'kibana.alert.rule.name': + aggMetrics.push(RULE_SIGNAL_ID_SUB_AGGREGATION, SEVERITY_SUB_AGGREGATION); + break; + default: + aggMetrics.push( + RULE_SIGNAL_ID_SUB_AGGREGATION, + SEVERITY_SUB_AGGREGATION, + RULE_COUNT_AGGREGATION + ); + } + return aggMetrics; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.test.tsx new file mode 100644 index 0000000000000..1bb861b2803e9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.test.tsx @@ -0,0 +1,256 @@ +/* + * 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 { + getIntegrationComponent, + groupStatsRenderer, + Integration, + INTEGRATION_ICON_TEST_ID, + INTEGRATION_LOADING_TEST_ID, +} from './group_stats_renderers'; +import type { GenericBuckets } from '@kbn/grouping/src'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id'; +import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks'; + +jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id'); +jest.mock('@kbn/fleet-plugin/public/hooks'); + +describe('Integration', () => { + it('should return a single integration icon', () => { + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: { + title: 'title', + icons: [{ type: 'type', src: 'src' }], + name: 'name', + version: 'version', + }, + isLoading: false, + }); + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + + const bucket: GenericBuckets = { key: 'crowdstrike', doc_count: 10 }; + + const { getByTestId } = render(); + + expect(getByTestId(INTEGRATION_ICON_TEST_ID)).toBeInTheDocument(); + }); + + it('should return a single integration loading', () => { + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: {}, + isLoading: true, + }); + + const bucket: GenericBuckets = { key: 'crowdstrike', doc_count: 10 }; + + const { getByTestId } = render(); + + expect(getByTestId(INTEGRATION_LOADING_TEST_ID)).toBeInTheDocument(); + }); +}); + +describe('getIntegrationComponent', () => { + it('should return an empty array', () => { + const groupStatsItems = getIntegrationComponent({ + key: '', + signalRuleIdSubAggregation: { buckets: [] }, + doc_count: 2, + }); + + expect(groupStatsItems.length).toBe(0); + }); + + it('should return a single integration', () => { + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: { title: 'title', icons: 'icons', name: 'name', version: 'version' }, + isLoading: false, + }); + + const groupStatsItems = getIntegrationComponent({ + key: '', + signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 10 }] }, + doc_count: 2, + }); + + expect(groupStatsItems.length).toBe(1); + expect(groupStatsItems[0].component).toMatchInlineSnapshot(` + + `); + }); + + it('should return a single integration loading', () => { + const groupStatsItems = getIntegrationComponent({ + key: '', + signalRuleIdSubAggregation: { + buckets: [ + { key: 'crowdstrike', doc_count: 10 }, + { + key: 'google_secops', + doc_count: 10, + }, + ], + }, + doc_count: 2, + }); + + expect(groupStatsItems.length).toBe(1); + expect(groupStatsItems[0].component).toMatchInlineSnapshot(` + + Multi + +`); + }); +}); + +describe('groupStatsRenderer', () => { + it('should return array of badges for signal.rule.id field', () => { + const badges = groupStatsRenderer('signal.rule.id', { + key: '', + severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] }, + rulesCountAggregation: { value: 3 }, + doc_count: 10, + }); + + expect(badges.length).toBe(3); + expect( + badges.find( + (badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => + badge.title === 'Rules:' && + badge.component == null && + badge.badge != null && + badge.badge.value === 3 + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => + badge.title === 'Alerts:' && + badge.component == null && + badge.badge != null && + badge.badge.value === 10 + ) + ).toBeTruthy(); + }); + + it('should return array of badges for kibana.alert.severity field', () => { + const badges = groupStatsRenderer('kibana.alert.severity', { + key: '', + signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 10 }] }, + rulesCountAggregation: { value: 4 }, + doc_count: 2, + }); + + expect(badges.length).toBe(3); + expect( + badges.find( + (badge) => badge.title === 'Integrations:' && badge.component != null && badge.badge == null + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => + badge.title === 'Rules:' && + badge.component == null && + badge.badge != null && + badge.badge.value === 4 + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => + badge.title === 'Alerts:' && + badge.component == null && + badge.badge != null && + badge.badge.value === 2 + ) + ).toBeTruthy(); + }); + + it('should return array of badges for kibana.alert.rule.name field', () => { + const badges = groupStatsRenderer('kibana.alert.rule.name', { + key: '', + signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 9 }] }, + severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 8 }] }, + doc_count: 1, + }); + + expect(badges.length).toBe(3); + expect( + badges.find( + (badge) => badge.title === 'Integrations:' && badge.component != null && badge.badge == null + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => + badge.title === 'Alerts:' && + badge.component == null && + badge.badge != null && + badge.badge.value === 1 + ) + ).toBeTruthy(); + }); + + it('should return default badges if the field does not exist', () => { + const badges = groupStatsRenderer('process.name', { + key: '', + signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 4 }] }, + severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 5 }] }, + rulesCountAggregation: { value: 2 }, + doc_count: 11, + }); + + expect(badges.length).toBe(4); + expect( + badges.find( + (badge) => badge.title === 'Integrations:' && badge.component != null && badge.badge == null + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => + badge.title === 'Rules:' && + badge.component == null && + badge.badge != null && + badge.badge.value === 2 + ) + ).toBeTruthy(); + expect( + badges.find( + (badge) => + badge.title === 'Alerts:' && + badge.component == null && + badge.badge != null && + badge.badge.value === 11 + ) + ).toBeTruthy(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.tsx new file mode 100644 index 0000000000000..8de66291cb89c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.tsx @@ -0,0 +1,125 @@ +/* + * 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 { EuiSkeletonText } from '@elastic/eui'; +import type { GroupStatsItem, RawBucket } from '@kbn/grouping'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { GenericBuckets } from '@kbn/grouping/src'; +import { CardIcon } from '@kbn/fleet-plugin/public'; +import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id'; +import { getRulesBadge, getSeverityComponent } from '../../alerts_table/grouping_settings'; +import { DEFAULT_GROUP_STATS_RENDERER } from '../../alerts_table/alerts_grouping'; +import type { AlertsGroupingAggregation } from '../../alerts_table/grouping_settings/types'; + +const STATS_GROUP_SIGNAL_RULE_ID = i18n.translate( + 'xpack.securitySolution.alertSummary.groups.integrations', + { + defaultMessage: 'Integrations:', + } +); +const STATS_GROUP_SIGNAL_RULE_ID_MULTI = i18n.translate( + 'xpack.securitySolution.alertSummary.groups.integrations.multi', + { + defaultMessage: ' Multi', + } +); + +export const INTEGRATION_ICON_TEST_ID = 'alert-summary-table-integration-cell-renderer-icon'; +export const INTEGRATION_LOADING_TEST_ID = 'alert-summary-table-integration-cell-renderer-loading'; + +interface IntegrationProps { + /** + * Aggregation buckets for integrations + */ + signalRuleIdBucket: GenericBuckets; +} + +/** + * Renders the icon for the integration that matches the rule id. + * In AI for SOC, we can retrieve the integration/package that matches a specific rule, via the related_integrations field on the rule. + */ +export const Integration = memo(({ signalRuleIdBucket }: IntegrationProps) => { + const signalRuleId = signalRuleIdBucket.key; + const { integration, isLoading } = useGetIntegrationFromRuleId({ ruleId: signalRuleId }); + + return ( + + {integration ? ( + + ) : null} + + ); +}); +Integration.displayName = 'Integration'; + +/** + * Return a renderer for integration aggregation. + */ +export const getIntegrationComponent = ( + bucket: RawBucket +): GroupStatsItem[] => { + const signalRuleIds = bucket.signalRuleIdSubAggregation?.buckets; + + if (!signalRuleIds || signalRuleIds.length === 0) { + return []; + } + + if (signalRuleIds.length === 1) { + return [ + { + title: STATS_GROUP_SIGNAL_RULE_ID, + component: , + }, + ]; + } + + return [ + { + title: STATS_GROUP_SIGNAL_RULE_ID, + component: <>{STATS_GROUP_SIGNAL_RULE_ID_MULTI}, + }, + ]; +}; + +/** + * Returns stats to be used in the`extraAction` property of the EuiAccordion component used within the kbn-grouping package. + * It handles custom renders for the following fields: + * - signal.rule.id + * - kibana.alert.severity + * - kibana.alert.rule.name + * And returns a default view for all the other fields. + * + * These go hand in hand with groupingOptions, groupTitleRenderers and groupStatsAggregations. + */ +export const groupStatsRenderer = ( + selectedGroup: string, + bucket: RawBucket +): GroupStatsItem[] => { + const defaultBadges: GroupStatsItem[] = DEFAULT_GROUP_STATS_RENDERER(selectedGroup, bucket); + const severityComponent: GroupStatsItem[] = getSeverityComponent(bucket); + const integrationComponent: GroupStatsItem[] = getIntegrationComponent(bucket); + const rulesBadge: GroupStatsItem = getRulesBadge(bucket); + + switch (selectedGroup) { + case 'signal.rule.id': + return [...severityComponent, rulesBadge, ...defaultBadges]; + case 'kibana.alert.severity': + return [...integrationComponent, rulesBadge, ...defaultBadges]; + case 'kibana.alert.rule.name': + return [...integrationComponent, ...severityComponent, ...defaultBadges]; + default: + return [...integrationComponent, ...severityComponent, rulesBadge, ...defaultBadges]; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.test.tsx new file mode 100644 index 0000000000000..692048a894bfe --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.test.tsx @@ -0,0 +1,165 @@ +/* + * 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 { + groupTitleRenderers, + INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID, + INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID, + INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID, + INTEGRATION_GROUP_RENDERER_TEST_ID, + IntegrationNameGroupContent, + SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID, +} from './group_title_renderers'; +import { render } from '@testing-library/react'; +import { defaultGroupTitleRenderers } from '../../alerts_table/grouping_settings'; +import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id'; +import React from 'react'; + +jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id'); + +describe('groupTitleRenderers', () => { + it('should render correctly for signal.rule.id field', () => { + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: { title: 'rule_name' }, + isLoading: false, + }); + + const { getByTestId } = render( + groupTitleRenderers( + 'signal.rule.id', + { + key: ['rule_id'], + doc_count: 10, + }, + 'This is a null group!' + )! + ); + + expect(getByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).toBeInTheDocument(); + }); + + it('should render correctly for kibana.alert.rule.name field', () => { + const { getByTestId } = render( + defaultGroupTitleRenderers( + 'kibana.alert.rule.name', + { + key: ['Rule name test', 'Some description'], + doc_count: 10, + }, + 'This is a null group!' + )! + ); + + expect(getByTestId('rule-name-group-renderer')).toBeInTheDocument(); + }); + + it('should render correctly for host.name field', () => { + const { getByTestId } = render( + defaultGroupTitleRenderers( + 'host.name', + { + key: 'Host', + doc_count: 2, + }, + 'This is a null group!' + )! + ); + + expect(getByTestId('host-name-group-renderer')).toBeInTheDocument(); + }); + + it('should render correctly for user.name field', () => { + const { getByTestId } = render( + defaultGroupTitleRenderers( + 'user.name', + { + key: 'User test', + doc_count: 1, + }, + 'This is a null group!' + )! + ); + + expect(getByTestId('user-name-group-renderer')).toBeInTheDocument(); + }); + + it('should render correctly for source.ip field', () => { + const { getByTestId } = render( + defaultGroupTitleRenderers( + 'source.ip', + { + key: 'sourceIp', + doc_count: 23, + }, + 'This is a null group!' + )! + ); + + expect(getByTestId('source-ip-group-renderer')).toBeInTheDocument(); + }); + + it('should return undefined when the renderer does not exist', () => { + const wrapper = groupTitleRenderers( + 'process.name', + { + key: 'process', + doc_count: 10, + }, + 'This is a null group!' + ); + + expect(wrapper).toBeUndefined(); + }); +}); + +describe('IntegrationNameGroupContent', () => { + it('should render the integration name and icon when a matching rule is found', () => { + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: { title: 'rule_name', icons: 'icon' }, + isLoading: false, + }); + + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID)).toHaveTextContent( + 'rule_name' + ); + expect(getByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render rule id when no matching rule is found', () => { + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: undefined, + isLoading: false, + }); + + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId(SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID)).toHaveTextContent('rule.id'); + expect(queryByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).not.toBeInTheDocument(); + expect( + queryByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID) + ).not.toBeInTheDocument(); + expect( + queryByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID) + ).not.toBeInTheDocument(); + }); + + it('should render loading for signal.rule.id field when rule and packages are loading', () => { + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: undefined, + isLoading: true, + }); + + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId(INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.tsx new file mode 100644 index 0000000000000..08bc96dc5f4de --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.tsx @@ -0,0 +1,133 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiTitle } from '@elastic/eui'; +import { isArray } from 'lodash/fp'; +import React, { memo } from 'react'; +import type { GroupPanelRenderer } from '@kbn/grouping/src'; +import { CardIcon } from '@kbn/fleet-plugin/public'; +import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id'; +import { GroupWithIconContent, RuleNameGroupContent } from '../../alerts_table/grouping_settings'; +import type { AlertsGroupingAggregation } from '../../alerts_table/grouping_settings/types'; +import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers'; + +/** + * Returns renderers to be used in the `buttonContent` property of the EuiAccordion component used within the kbn-grouping package. + * It handles custom renders for the following fields: + * - signal.rule.id + * - kibana.alert.rule.name + * - host.name + * - user.name + * - source.ip + * For all the other fields the default renderer managed within the kbn-grouping package will be used. + * + * These go hand in hand with groupingOptions, groupStatsRenderer and groupStatsAggregations. + */ +export const groupTitleRenderers: GroupPanelRenderer = ( + selectedGroup, + bucket, + nullGroupMessage +) => { + switch (selectedGroup) { + case 'signal.rule.id': + return ; + case 'kibana.alert.rule.name': + return isArray(bucket.key) ? ( + + ) : undefined; + case 'host.name': + return ( + + ); + case 'user.name': + return ( + + ); + case 'source.ip': + return ( + + ); + } +}; + +export const INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID = 'integration-group-renderer-loading'; +export const INTEGRATION_GROUP_RENDERER_TEST_ID = 'integration-group-renderer'; +export const INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID = + 'integration-group-renderer-integration-name'; +export const INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID = + 'integration-group-renderer-integration-icon'; +export const SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID = 'signal-rule-id-group-renderer'; + +/** + * Renders an icon and name of an integration. + */ +export const IntegrationNameGroupContent = memo<{ + title: string | string[]; +}>(({ title }) => { + const { integration, isLoading } = useGetIntegrationFromRuleId({ ruleId: title }); + + return ( + + {integration ? ( + + + + + + +
{integration.title}
+
+
+
+ ) : ( + +
{title}
+
+ )} +
+ ); +}); +IntegrationNameGroupContent.displayName = 'IntegrationNameGroup'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/grouping_options.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/grouping_options.tsx new file mode 100644 index 0000000000000..0df1df8153617 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/grouping_options.tsx @@ -0,0 +1,48 @@ +/* + * 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 { GroupOption } from '@kbn/grouping/src'; +import { i18n } from '@kbn/i18n'; + +const INTEGRATION_NAME = i18n.translate( + 'xpack.securitySolution.alertsTable.groups.integrationName', + { + defaultMessage: 'Integration', + } +); + +const SEVERITY = i18n.translate('xpack.securitySolution.alertsTable.groups.severity', { + defaultMessage: 'Severity', +}); + +const RULE_NAME = i18n.translate('xpack.securitySolution.alertsTable.groups.ruleName', { + defaultMessage: 'Rule name', +}); + +/** + * Returns a list of fields for the default grouping options. These are displayed in the `Group alerts by` dropdown button. + * The default values are: + * - signal.rule.id + * - kibana.alert.severity + * - kibana.alert.rule.name + * + * These go hand in hand with groupTitleRenderers, groupStatsRenderer and groupStatsAggregations + */ +export const groupingOptions: GroupOption[] = [ + { + label: INTEGRATION_NAME, + key: 'signal.rule.id', + }, + { + label: SEVERITY, + key: 'kibana.alert.severity', + }, + { + label: RULE_NAME, + key: 'kibana.alert.rule.name', + }, +]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.test.tsx new file mode 100644 index 0000000000000..1940b669b72ec --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.test.tsx @@ -0,0 +1,117 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import type { Alert } from '@kbn/alerting-types'; +import { CellValue } from './render_cell'; +import { TestProviders } from '../../../../common/mock'; +import { getEmptyValue } from '../../../../common/components/empty_value'; + +describe('CellValue', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const columnId = 'columnId'; + + const { getByText } = render( + + + + ); + + expect(getByText(getEmptyValue())).toBeInTheDocument(); + }); + + it('should handle string value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const columnId = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('value1')).toBeInTheDocument(); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const columnId = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('true, false')).toBeInTheDocument(); + }); + + it('should handle array of numbers', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [1, 2], + }; + const columnId = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('1, 2')).toBeInTheDocument(); + }); + + it('should handle array of null', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [null, null], + }; + const columnId = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText(',')).toBeInTheDocument(); + }); + + it('should join array of JsonObjects', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [{ subField1: 'value1', subField2: 'value2' }], + }; + const columnId = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('[object Object]')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.tsx new file mode 100644 index 0000000000000..313f73de634f0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.tsx @@ -0,0 +1,62 @@ +/* + * 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 React, { memo, useMemo } from 'react'; +import type { Alert } from '@kbn/alerting-types'; +import type { JsonValue } from '@kbn/utility-types'; +import { getOrEmptyTagFromValue } from '../../../../common/components/empty_value'; + +const styles = { display: 'flex', alignItems: 'center', height: '100%' }; + +export interface CellValueProps { + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + alert: Alert; + /** + * Column id passed from the renderCellValue callback via EuiDataGridProps['renderCellValue'] interface + */ + columnId: string; +} + +/** + * Component used in the AI for SOC alert summary table. + * It renders all the values currently as simply as possible (see code comments below). + * It will be soon improved to support custom renders for specific fields (like kibana.alert.rule.parameters and kibana.alert.severity). + */ +export const CellValue = memo(({ alert, columnId }: CellValueProps) => { + const displayValue: string | null = useMemo(() => { + const cellValues: string | JsonValue[] = alert[columnId]; + + // Displays string as is. + // Joins values of array with more than one element. + // Returns null if the value is null. + // Return the string of the value otherwise. + if (typeof cellValues === 'string') { + return cellValues; + } else if (Array.isArray(cellValues)) { + if (cellValues.length > 1) { + return cellValues.join(', '); + } else { + const value: JsonValue = cellValues[0]; + if (typeof value === 'string') { + return value; + } else if (value == null) { + return null; + } else { + return value.toString(); + } + } + } else { + return null; + } + }, [alert, columnId]); + + return
{getOrEmptyTagFromValue(displayValue)}
; +}); + +CellValue.displayName = 'CellValue'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.test.tsx new file mode 100644 index 0000000000000..f045ec086c91f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; +import { TestProviders } from '../../../../common/mock'; +import { Table } from './table'; + +const dataView: DataView = createStubDataView({ spec: {} }); + +describe('', () => { + it('should render all components', () => { + const { getByTestId } = render( + +
+ + ); + + expect(getByTestId('alertsTableErrorPrompt')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx new file mode 100644 index 0000000000000..6c4d1e33cc9d6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx @@ -0,0 +1,196 @@ +/* + * 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 React, { memo, useCallback, useMemo } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { Filter } from '@kbn/es-query'; +import { getEsQueryConfig } from '@kbn/data-service'; +import { i18n } from '@kbn/i18n'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { AlertsTable } from '@kbn/response-ops-alerts-table'; +import type { AlertsTableProps } from '@kbn/response-ops-alerts-table/types'; +import { AlertConsumers } from '@kbn/rule-data-utils'; +import { ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; +import type { + EuiDataGridProps, + EuiDataGridStyle, + EuiDataGridToolBarVisibilityOptions, +} from '@elastic/eui'; +import { AdditionalToolbarControls } from './additional_toolbar_controls'; +import { getDataViewStateFromIndexFields } from '../../../../common/containers/source/use_data_view'; +import { inputsSelectors } from '../../../../common/store'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { combineQueries } from '../../../../common/lib/kuery'; +import { useKibana } from '../../../../common/lib/kibana'; +import { CellValue } from './render_cell'; +import { buildTimeRangeFilter } from '../../alerts_table/helpers'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; + +const TIMESTAMP_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.timeStamp', + { defaultMessage: 'Timestamp' } +); +const RELATION_INTEGRATION_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.relatedIntegrationName', + { defaultMessage: 'Integration' } +); +const SEVERITY_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.severity', + { defaultMessage: 'Severity' } +); +const RULE_NAME_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.ruleName', + { defaultMessage: 'Rule' } +); + +const TIMESTAMP = '@timestamp'; +const RELATED_INTEGRATION = 'kibana.alert.rule.parameters'; +const SEVERITY = 'kibana.alert.severity'; +const RULE_NAME = 'kibana.alert.rule.name'; + +const columns: EuiDataGridProps['columns'] = [ + { + id: TIMESTAMP, + displayAsText: TIMESTAMP_COLUMN, + }, + { + id: RELATED_INTEGRATION, + displayAsText: RELATION_INTEGRATION_COLUMN, + }, + { + id: SEVERITY, + displayAsText: SEVERITY_COLUMN, + }, + { + id: RULE_NAME, + displayAsText: RULE_NAME_COLUMN, + }, +]; + +const ALERT_TABLE_CONSUMERS: AlertsTableProps['consumers'] = [AlertConsumers.SIEM]; +const RULE_TYPE_IDS = [ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID]; +const ROW_HEIGHTS_OPTIONS = { defaultHeight: 40 }; +const TOOLBAR_VISIBILITY: EuiDataGridToolBarVisibilityOptions = { + showDisplaySelector: false, + showKeyboardShortcuts: false, + showFullScreenSelector: false, +}; +const GRID_STYLE: EuiDataGridStyle = { border: 'horizontal' }; + +export interface TableProps { + /** + * DataView created for the alert summary page + */ + dataView: DataView; + /** + * Groups filters passed from the GroupedAlertsTable component via the renderChildComponent callback + */ + groupingFilters: Filter[]; +} + +/** + * Renders the table showing all the alerts. This component leverages the ResponseOps AlertsTable in a similar way that the alerts page does. + * The table is used in combination with the GroupedAlertsTable component. + */ +export const Table = memo(({ dataView, groupingFilters }: TableProps) => { + const { + services: { + application, + data, + fieldFormats, + http, + licensing, + notifications, + uiSettings, + settings, + }, + } = useKibana(); + const services = useMemo( + () => ({ + data, + http, + notifications, + fieldFormats, + application, + licensing, + settings, + }), + [application, data, fieldFormats, http, licensing, notifications, settings] + ); + + const getGlobalFiltersSelector = useMemo(() => inputsSelectors.globalFiltersQuerySelector(), []); + const globalFilters = useDeepEqualSelector(getGlobalFiltersSelector); + + const { to, from } = useGlobalTime(); + const timeRangeFilter = useMemo(() => buildTimeRangeFilter(from, to), [from, to]); + + const filters = useMemo( + () => [ + ...globalFilters, + ...timeRangeFilter, + ...groupingFilters.filter((filter) => filter.meta.type !== 'custom'), + ], + [globalFilters, groupingFilters, timeRangeFilter] + ); + + const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]); + + const { browserFields } = useMemo( + () => getDataViewStateFromIndexFields('', dataViewSpec.fields), + [dataViewSpec.fields] + ); + + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const globalQuery = useDeepEqualSelector(getGlobalQuerySelector); + + const query: AlertsTableProps['query'] = useMemo(() => { + const combinedQuery = combineQueries({ + config: getEsQueryConfig(uiSettings), + dataProviders: [], + dataViewSpec, + browserFields, + filters, + kqlQuery: globalQuery, + kqlMode: globalQuery.language, + }); + + if (combinedQuery?.kqlError || !combinedQuery?.filterQuery) { + return { bool: {} }; + } + + try { + const filter = JSON.parse(combinedQuery?.filterQuery); + return { bool: { filter } }; + } catch { + return { bool: {} }; + } + }, [browserFields, dataViewSpec, filters, globalQuery, uiSettings]); + + const renderAdditionalToolbarControls = useCallback( + () => , + [dataView] + ); + + return ( + + ); +}); + +Table.displayName = 'Table'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.test.tsx new file mode 100644 index 0000000000000..d7ca8e74ef0d9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.test.tsx @@ -0,0 +1,28 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; +import { TestProviders } from '../../../../common/mock'; +import { GROUPED_TABLE_TEST_ID, TableSection } from './table_section'; + +const dataView: DataView = createStubDataView({ spec: {} }); + +describe('', () => { + it('should render all components', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId(GROUPED_TABLE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId('alertsTableErrorPrompt')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.tsx new file mode 100644 index 0000000000000..db350df9f66e8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.tsx @@ -0,0 +1,86 @@ +/* + * 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 React, { memo, useCallback, useMemo } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { Filter } from '@kbn/es-query'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { groupStatsRenderer } from './group_stats_renderers'; +import { groupingOptions } from './grouping_options'; +import { groupTitleRenderers } from './group_title_renderers'; +import type { RunTimeMappings } from '../../../../sourcerer/store/model'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { Table } from './table'; +import { inputsSelectors } from '../../../../common/store'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { GroupedAlertsTable } from '../../alerts_table/alerts_grouping'; +import { groupStatsAggregations } from './group_stats_aggregations'; +import { useUserData } from '../../user_info'; + +export const GROUPED_TABLE_TEST_ID = 'alert-summary-grouped-table'; + +const runtimeMappings: RunTimeMappings = {}; + +export interface TableSectionProps { + /** + * DataView created for the alert summary page + */ + dataView: DataView; +} + +/** + * Section rendering the table in the alert summary page. + * This component leverages the GroupedAlertsTable and the ResponseOps AlertsTable also used in the alerts page. + */ +export const TableSection = memo(({ dataView }: TableSectionProps) => { + const indexNames = useMemo(() => dataView.getIndexPattern(), [dataView]); + const { to, from } = useGlobalTime(); + + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const globalQuery = useDeepEqualSelector(getGlobalQuerySelector); + + const getGlobalFiltersSelector = useMemo(() => inputsSelectors.globalFiltersQuerySelector(), []); + const filters = useDeepEqualSelector(getGlobalFiltersSelector); + + const [{ hasIndexWrite, hasIndexMaintenance }] = useUserData(); + + const accordionExtraActionGroupStats = useMemo( + () => ({ + aggregations: groupStatsAggregations, + renderer: groupStatsRenderer, + }), + [] + ); + + const renderChildComponent = useCallback( + (groupingFilters: Filter[]) =>
, + [dataView] + ); + + return ( +
+ +
+ ); +}); + +TableSection.displayName = 'TableSection'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx index 5d010a04c64b9..cc8f88d51e58d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx @@ -23,11 +23,15 @@ import { useIntegrationsLastActivity } from '../../hooks/alert_summary/use_integ import { ADD_INTEGRATIONS_BUTTON_TEST_ID } from './integrations/integration_section'; import { SEARCH_BAR_TEST_ID } from './search_bar/search_bar_section'; import { KPIS_SECTION } from './kpis/kpis_section'; +import { GROUPED_TABLE_TEST_ID } from './table/table_section'; jest.mock('../../../common/components/search_bar', () => ({ // The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables so we can't use SEARCH_BAR_TEST_ID SiemSearchBar: () =>
, })); +jest.mock('../alerts_table/alerts_grouping', () => ({ + GroupedAlertsTable: () =>
, +})); jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/hooks/use_add_integrations_url'); jest.mock('../../hooks/alert_summary/use_integrations_last_activity'); @@ -130,6 +134,7 @@ describe('', () => { expect(getByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID)).toBeInTheDocument(); expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument(); expect(getByTestId(KPIS_SECTION)).toBeInTheDocument(); + expect(getByTestId(GROUPED_TABLE_TEST_ID)).toBeInTheDocument(); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx index 39804a806817a..2f5b65aebf9d5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx @@ -20,6 +20,7 @@ import { useKibana } from '../../../common/lib/kibana'; import { KPIsSection } from './kpis/kpis_section'; import { IntegrationSection } from './integrations/integration_section'; import { SearchBarSection } from './search_bar/search_bar_section'; +import { TableSection } from './table/table_section'; const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.alertSummary.dataViewError', { defaultMessage: 'Unable to create data view', @@ -98,6 +99,8 @@ export const Wrapper = memo(({ packages }: WrapperProps) => { + +
)} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_aggregations.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_aggregations.ts index 71335b138a576..2f910461f52fb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_aggregations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_aggregations.ts @@ -54,42 +54,34 @@ export const defaultGroupStatsAggregations = (field: string): NamedAggregation[] switch (field) { case 'kibana.alert.rule.name': aggMetrics.push( - ...[ - { - description: { - terms: { - field: 'kibana.alert.rule.description', - size: 1, - }, + { + description: { + terms: { + field: 'kibana.alert.rule.description', + size: 1, }, }, - SEVERITY_SUB_AGGREGATION, - USER_COUNT_AGGREGATION, - HOST_COUNT_AGGREGATION, - { - ruleTags: { - terms: { - field: 'kibana.alert.rule.tags', - }, + }, + SEVERITY_SUB_AGGREGATION, + USER_COUNT_AGGREGATION, + HOST_COUNT_AGGREGATION, + { + ruleTags: { + terms: { + field: 'kibana.alert.rule.tags', }, }, - ] + } ); break; case 'host.name': - aggMetrics.push( - ...[RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, USER_COUNT_AGGREGATION] - ); + aggMetrics.push(RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, USER_COUNT_AGGREGATION); break; case 'user.name': - aggMetrics.push( - ...[RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, HOST_COUNT_AGGREGATION] - ); + aggMetrics.push(RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, HOST_COUNT_AGGREGATION); break; case 'source.ip': - aggMetrics.push( - ...[RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, HOST_COUNT_AGGREGATION] - ); + aggMetrics.push(RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, HOST_COUNT_AGGREGATION); break; default: aggMetrics.push(RULE_COUNT_AGGREGATION); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_renderers.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_renderers.tsx index 2d472dc39e642..0ef32bf566151 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_renderers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/default_group_stats_renderers.tsx @@ -13,19 +13,19 @@ import { DEFAULT_GROUP_STATS_RENDERER } from '../alerts_grouping'; import type { AlertsGroupingAggregation } from './types'; import * as i18n from '../translations'; -export const getUsersBadge = (bucket: RawBucket) => ({ +export const getUsersBadge = (bucket: RawBucket): GroupStatsItem => ({ title: i18n.STATS_GROUP_USERS, badge: { value: bucket.usersCountAggregation?.value ?? 0, }, }); -export const getHostsBadge = (bucket: RawBucket) => ({ +export const getHostsBadge = (bucket: RawBucket): GroupStatsItem => ({ title: i18n.STATS_GROUP_HOSTS, badge: { value: bucket.hostsCountAggregation?.value ?? 0, }, }); -export const getRulesBadge = (bucket: RawBucket) => ({ +export const getRulesBadge = (bucket: RawBucket): GroupStatsItem => ({ title: i18n.STATS_GROUP_RULES, badge: { value: bucket.rulesCountAggregation?.value ?? 0, @@ -57,7 +57,6 @@ export const Severity = memo(({ severities }: SingleSeverityProps) => { - @@ -137,17 +136,20 @@ export const defaultGroupStatsRenderer = ( selectedGroup: string, bucket: RawBucket ): GroupStatsItem[] => { - const severityStat: GroupStatsItem[] = getSeverityComponent(bucket); + const severityComponent: GroupStatsItem[] = getSeverityComponent(bucket); const defaultBadges: GroupStatsItem[] = DEFAULT_GROUP_STATS_RENDERER(selectedGroup, bucket); + const usersBadge: GroupStatsItem = getUsersBadge(bucket); + const hostsBadge: GroupStatsItem = getHostsBadge(bucket); + const rulesBadge: GroupStatsItem = getRulesBadge(bucket); switch (selectedGroup) { case 'kibana.alert.rule.name': - return [...severityStat, getUsersBadge(bucket), getHostsBadge(bucket), ...defaultBadges]; + return [...severityComponent, usersBadge, hostsBadge, ...defaultBadges]; case 'host.name': - return [...severityStat, getUsersBadge(bucket), getRulesBadge(bucket), ...defaultBadges]; + return [...severityComponent, usersBadge, rulesBadge, ...defaultBadges]; case 'user.name': case 'source.ip': - return [...severityStat, getHostsBadge(bucket), getRulesBadge(bucket), ...defaultBadges]; + return [...severityComponent, hostsBadge, rulesBadge, ...defaultBadges]; } - return [...severityStat, getRulesBadge(bucket), ...defaultBadges]; + return [...severityComponent, rulesBadge, ...defaultBadges]; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/types.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/types.ts index 0c7e4e686c37c..73a7c1902d0cb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/types.ts @@ -33,4 +33,7 @@ export interface AlertsGroupingAggregation { sum_other_doc_count?: number; buckets?: GenericBuckets[]; }; + signalRuleIdSubAggregation?: { + buckets?: GenericBuckets[]; + }; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_get_integration_from_rule_id.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_get_integration_from_rule_id.test.ts new file mode 100644 index 0000000000000..b4980a7a82767 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_get_integration_from_rule_id.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { useFetchIntegrations } from './use_fetch_integrations'; +import { useGetIntegrationFromRuleId } from './use_get_integration_from_rule_id'; + +jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query'); +jest.mock('./use_fetch_integrations'); + +describe('useGetIntegrationFromRuleId', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined integration when no matching rule is found', () => { + (useFindRulesQuery as jest.Mock).mockReturnValue({ data: { rules: [] }, isLoading: false }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: false, + }); + + const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' })); + + expect(result.current.isLoading).toBe(false); + expect(result.current.integration).toBe(undefined); + }); + + it('should render loading true is rules are loading', () => { + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [{ name: 'rule_name' }], + isLoading: false, + }); + + const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' })); + + expect(result.current.isLoading).toBe(true); + expect(result.current.integration).toBe(undefined); + }); + + it('should render loading true if packages are loading', () => { + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: { rules: [] }, + isLoading: false, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: true, + }); + + const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' })); + + expect(result.current.isLoading).toBe(true); + expect(result.current.integration).toBe(undefined); + }); + + it('should render a matching integration', () => { + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: { rules: [{ id: 'rule_id', name: 'rule_name' }] }, + isLoading: false, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [{ name: 'rule_name' }], + isLoading: false, + }); + + const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: 'rule_id' })); + + expect(result.current.isLoading).toBe(false); + expect(result.current.integration).toEqual({ name: 'rule_name' }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_get_integration_from_rule_id.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_get_integration_from_rule_id.ts new file mode 100644 index 0000000000000..5d1638aee5446 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_get_integration_from_rule_id.ts @@ -0,0 +1,66 @@ +/* + * 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 { useMemo } from 'react'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { useFetchIntegrations } from './use_fetch_integrations'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; + +export interface UseGetIntegrationFromRuleIdParams { + /** + * Id of the rule. This should be the value from the signal.rule.id field + */ + ruleId: string | string[]; +} + +export interface UseGetIntegrationFromRuleIdResult { + /** + * List of integrations ready to be consumed by the IntegrationFilterButton component + */ + integration: PackageListItem | undefined; + /** + * True while rules are being fetched + */ + isLoading: boolean; +} + +/** + * Hook that fetches rule and packages data. It then uses that data to find if there is a package (integration) + * that matches the rule id value passed via prop (value for the signal.rule.id field). + * + * This hook is used in the GroupedAlertTable's accordion when grouping by signal.rule.id, to render the title as well as statistics. + */ +export const useGetIntegrationFromRuleId = ({ + ruleId, +}: UseGetIntegrationFromRuleIdParams): UseGetIntegrationFromRuleIdResult => { + // Fetch all rules. For the AI for SOC effort, there should only be one rule per integration (which means for now 5-6 rules total) + const { data, isLoading: ruleIsLoading } = useFindRulesQuery({}); + + // Fetch all packages + const { installedPackages, isLoading: integrationIsLoading } = useFetchIntegrations(); + + // From the ruleId (which should be a value for a signal.rule.id field) we find the rule + // of the same id, which we then use its name to match a package's name. + const integration: PackageListItem | undefined = useMemo(() => { + const signalRuleId = Array.isArray(ruleId) ? ruleId[0] : ruleId; + const rule = (data?.rules || []).find((r: RuleResponse) => r.id === signalRuleId); + if (!rule) { + return undefined; + } + + return installedPackages.find((installedPackage) => installedPackage.name === rule.name); + }, [data?.rules, installedPackages, ruleId]); + + return useMemo( + () => ({ + integration, + isLoading: ruleIsLoading || integrationIsLoading, + }), + [integration, integrationIsLoading, ruleIsLoading] + ); +};