diff --git a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.test.tsx index 62f88d4238bda..c97f33c7c0f83 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.test.tsx +++ b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.test.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { waitFor, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import { MaintenanceWindowStatus } from '@kbn/alerting-plugin/common'; import * as api from '../apis/bulk_get_maintenance_windows'; import { coreMock } from '@kbn/core/public/mocks'; @@ -74,16 +74,17 @@ describe('useBulkGetMaintenanceWindowsQuery', () => { beforeEach(async () => { jest.clearAllMocks(); addErrorMock = notifications.toasts.addError as jest.Mock; + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); + }); + + it('calls the api when invoked with the correct parameters', async () => { application.capabilities = { ...application.capabilities, maintenanceWindow: { show: true, }, }; - useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); - }); - it('calls the api when invoked with the correct parameters', async () => { const spy = jest.spyOn(api, 'bulkGetMaintenanceWindows'); spy.mockResolvedValue(response); @@ -110,6 +111,13 @@ describe('useBulkGetMaintenanceWindowsQuery', () => { }); it('does not call the api if the canFetchMaintenanceWindows is false', async () => { + application.capabilities = { + ...application.capabilities, + maintenanceWindow: { + show: true, + }, + }; + const spy = jest.spyOn(api, 'bulkGetMaintenanceWindows'); spy.mockResolvedValue(response); @@ -136,6 +144,13 @@ describe('useBulkGetMaintenanceWindowsQuery', () => { }); it('does not call the api if license is not platinum', async () => { + application.capabilities = { + ...application.capabilities, + maintenanceWindow: { + show: true, + }, + }; + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); const spy = jest.spyOn(api, 'bulkGetMaintenanceWindows'); @@ -186,7 +201,35 @@ describe('useBulkGetMaintenanceWindowsQuery', () => { await waitFor(() => expect(spy).not.toHaveBeenCalled()); }); + it('does not call the api if the maintenanceWindow capability is disabled', async () => { + const spy = jest.spyOn(api, 'bulkGetMaintenanceWindows'); + spy.mockResolvedValue(response); + + renderHook( + () => + useBulkGetMaintenanceWindowsQuery({ + ids: ['test-id'], + http, + notifications, + application, + licensing, + }), + { + wrapper, + } + ); + + await waitFor(() => expect(spy).not.toHaveBeenCalled()); + }); + it('shows a toast error when the api return an error', async () => { + application.capabilities = { + ...application.capabilities, + maintenanceWindow: { + show: true, + }, + }; + const spy = jest .spyOn(api, 'bulkGetMaintenanceWindows') .mockRejectedValue(new Error('An error')); diff --git a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.tsx b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.tsx index 42e370107aa42..f12a9bc946b81 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.tsx +++ b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_get_maintenance_windows.tsx @@ -55,11 +55,7 @@ export const useBulkGetMaintenanceWindowsQuery = ( ids, http, notifications: { toasts }, - application: { - capabilities: { - maintenanceWindow: { show }, - }, - }, + application: { capabilities }, licensing, }: UseBulkGetMaintenanceWindowsQueryParams, { @@ -70,6 +66,9 @@ export const useBulkGetMaintenanceWindowsQuery = ( const { isAtLeastPlatinum } = useLicense({ licensing }); const hasLicense = isAtLeastPlatinum(); + // In AI4DSOC (searchAiLake tier) the maintenanceWindow capability is disabled + const show = Boolean(capabilities.maintenanceWindow?.show); + return useQuery({ queryKey: queryKeys.maintenanceWindowsBulkGet(ids), queryFn: () => bulkGetMaintenanceWindows({ http, ids }), diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx index 723ddf823dd1e..6d7706f7e0ece 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.test.tsx @@ -54,6 +54,30 @@ describe('ContextPills', () => { }); }); + it('does not render item if description is empty', () => { + render( + + Promise.resolve('Context 2 data'), + id: 'context3', + tooltip: 'Context 2 tooltip', + }, + }} + selectedPromptContexts={{}} + setSelectedPromptContexts={jest.fn()} + /> + + ); + expect(screen.getByTestId(`pillButton-context2`)).toBeInTheDocument(); + expect(screen.queryByTestId(`pillButton-context3`)).not.toBeInTheDocument(); + }); + it('invokes setSelectedPromptContexts() when the prompt is NOT already selected', async () => { const context = mockPromptContexts.context1; const setSelectedPromptContexts = jest.fn(); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx index 98335add50b20..6512b3065e704 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/context_pills/index.tsx @@ -67,7 +67,7 @@ const ContextPillsComponent: React.FC = ({ {description} ); - return ( + return description.length > 0 ? ( {selectedPromptContexts[id] != null ? ( button @@ -75,7 +75,7 @@ const ContextPillsComponent: React.FC = ({ {button} )} - ); + ) : null; })} ); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx index 299dab193d2f2..68371599fa78f 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx @@ -6,10 +6,10 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { NewChatByTitle } from '.'; +import { BUTTON_ICON_TEST_ID, BUTTON_TEST_ID, BUTTON_TEXT_TEST_ID, NewChatByTitle } from '.'; const testProps = { showAssistantOverlay: jest.fn(), @@ -20,60 +20,28 @@ describe('NewChatByTitle', () => { jest.clearAllMocks(); }); - it('renders the default New Chat button with a discuss icon', () => { - render(); + it('should render icon only by default', () => { + const { getByTestId, queryByTestId } = render(); - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument(); - }); - - it('renders the default "New Chat" text when children are NOT provided', () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.textContent).toContain('Chat'); + expect(getByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(BUTTON_ICON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(BUTTON_TEXT_TEST_ID)).not.toBeInTheDocument(); }); - it('renders custom children', async () => { - render({'🪄✨'}); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.textContent).toContain('🪄✨'); - }); - - it('renders custom icons', async () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); + it('should render the button with icon and text', () => { + const { getByTestId } = render(); - expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument(); - }); - - it('does NOT render an icon when iconType is null', () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument(); - }); - - it('renders button icon when iconOnly is true', async () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument(); - expect(newChatButton.textContent).not.toContain('Chat'); + expect(getByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(BUTTON_ICON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(BUTTON_TEXT_TEST_ID)).toHaveTextContent('Ask AI Assistant'); }); it('calls showAssistantOverlay on click', async () => { - render(); - const newChatButton = screen.getByTestId('newChatByTitle'); + const { getByTestId } = render(); + + const button = getByTestId(BUTTON_TEST_ID); - await userEvent.click(newChatButton); + await userEvent.click(button); expect(testProps.showAssistantOverlay).toHaveBeenCalledWith(true); }); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx index 9f21c5764fa1f..bb22b14a48e28 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx @@ -5,76 +5,78 @@ * 2.0. */ -import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; - +import type { EuiButtonColor } from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { AssistantIcon } from '@kbn/ai-assistant-icon'; +import { EuiButtonEmptySizes } from '@elastic/eui/src/components/button/button_empty/button_empty'; import * as i18n from './translations'; -export interface Props { - children?: React.ReactNode; - /** Defaults to `discuss`. If null, the button will not have an icon */ - iconType?: string | null; +export const BUTTON_TEST_ID = 'newChatByTitle'; +export const BUTTON_ICON_TEST_ID = 'newChatByTitleIcon'; +export const BUTTON_TEXT_TEST_ID = 'newChatByTitleText'; + +export interface NewChatByTitleComponentProps { + /** + * Optionally specify color of empty button. + * @default 'primary' + */ + color?: EuiButtonColor; + /** + * Callback to display the assistant overlay + */ showAssistantOverlay: (show: boolean) => void; - /** Defaults to false. If true, shows icon button without text */ - iconOnly?: boolean; + /** + * + */ + size?: EuiButtonEmptySizes; + /** + * Optionally specify the text to display. + */ + text?: string; } -const NewChatByTitleComponent: React.FC = ({ - children = i18n.NEW_CHAT, - iconType, +const NewChatByTitleComponent: React.FC = ({ + color = 'primary', showAssistantOverlay, - iconOnly = false, + size = 'm', + text, }) => { - const showOverlay = useCallback(() => { - showAssistantOverlay(true); - }, [showAssistantOverlay]); - - const icon = useMemo(() => { - if (iconType === null) { - return undefined; - } - - return iconType ?? 'discuss'; - }, [iconType]); + const showOverlay = useCallback(() => showAssistantOverlay(true), [showAssistantOverlay]); - return useMemo( - () => - iconOnly ? ( - - - - ) : ( - - {children} - - ), - [children, icon, showOverlay, iconOnly] + return ( + + + + + + {text && ( + + {text} + + )} + + ); }; NewChatByTitleComponent.displayName = 'NewChatByTitleComponent'; /** - * `NewChatByTitle` displays a _New chat_ icon button by providing only the `promptContextId` + * `NewChatByTitle` displays a button by providing only the `promptContextId` * of a context that was (already) registered by the `useAssistantOverlay` hook. You may - * optionally style the button icon, or override the default _New chat_ text with custom - * content, like {'🪄✨'} + * optionally override the default text. * * USE THIS WHEN: all the data necessary to start a new chat is NOT available - * in the same part of the React tree as the _New chat_ button. When paired - * with the `useAssistantOverlay` hook, this option enables context to be be - * registered where the data is available, and then the _New chat_ button can be displayed + * in the same part of the React tree as the button. When paired + * with the `useAssistantOverlay` hook, this option enables context to be + * registered where the data is available, and then the button can be displayed * in another part of the tree. */ export const NewChatByTitle = React.memo(NewChatByTitleComponent); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts index 57de1f990dc6c..590e0a67e9634 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts @@ -7,9 +7,9 @@ import { i18n } from '@kbn/i18n'; -export const NEW_CHAT = i18n.translate( +export const ASK_AI_ASSISTANT = i18n.translate( 'xpack.elasticAssistant.assistant.newChatByTitle.newChatByTitleButton', { - defaultMessage: 'Chat', + defaultMessage: 'Ask AI Assistant', } ); diff --git a/x-pack/platform/plugins/shared/fleet/common/index.ts b/x-pack/platform/plugins/shared/fleet/common/index.ts index dc1efbe67d353..f7d0a9bd12d5b 100644 --- a/x-pack/platform/plugins/shared/fleet/common/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/index.ts @@ -123,6 +123,7 @@ export type { // Models Agent, AgentStatus, + DataStream, FleetServerAgentMetadata, AgentMetadata, NewAgentPolicy, diff --git a/x-pack/platform/plugins/shared/fleet/public/index.ts b/x-pack/platform/plugins/shared/fleet/public/index.ts index d82e9c88b7db8..f5d95f1325aba 100644 --- a/x-pack/platform/plugins/shared/fleet/public/index.ts +++ b/x-pack/platform/plugins/shared/fleet/public/index.ts @@ -10,6 +10,7 @@ import type { PluginInitializerContext } from '@kbn/core/public'; import { lazy } from 'react'; import { FleetPlugin } from './plugin'; + export type { GetPackagesResponse } from './types'; export { installationStatuses } from '../common/constants'; @@ -60,7 +61,7 @@ export { pagePathGetters, EPM_API_ROUTES } from './constants'; export { pkgKeyFromPackageInfo } from './services'; export type { CustomAssetsAccordionProps } from './components/custom_assets_accordion'; export { CustomAssetsAccordion } from './components/custom_assets_accordion'; -export { PackageIcon } from './components/package_icon'; +export { CardIcon, PackageIcon } from './components/package_icon'; // Export Package editor components for custom editors export { PackagePolicyEditorDatastreamPipelines } from './applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_pipelines'; export type { PackagePolicyEditorDatastreamPipelinesProps } from './applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_pipelines'; @@ -89,3 +90,8 @@ export const AvailablePackagesHook = () => { './applications/integrations/sections/epm/screens/home/hooks/use_available_packages' ); }; + +export { useGetDataStreams } from './hooks/use_request/data_stream'; +export { useGetPackagesQuery } from './hooks/use_request/epm'; +export { useGetSettingsQuery } from './hooks/use_request/settings'; +export { useLink } from './hooks/use_link'; 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/common/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/constants.ts index 34659c9ad8cca..8ca5438e2212d 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/constants.ts @@ -94,6 +94,7 @@ export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; export const ALERT_DETAILS_REDIRECT_PATH = `${ALERTS_PATH}/redirect` as const; +export const ALERT_SUMMARY_PATH = `/alert_summary` as const; export const RULES_PATH = '/rules' as const; export const RULES_LANDING_PATH = `${RULES_PATH}/landing` as const; export const RULES_ADD_PATH = `${RULES_PATH}/add_rules` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts index d3e1a3dccc93d..6048dc08c2239 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/links/app_links.ts @@ -10,11 +10,11 @@ import { configurationsLinks } from '../../configurations/links'; import { links as attackDiscoveryLinks } from '../../attack_discovery/links'; import type { AppLinkItems } from '../../common/links/types'; import { indicatorsLinks } from '../../threat_intelligence/links'; -import { links as alertsLink } from '../../detections/links'; +import { alertsLink, alertSummaryLink } from '../../detections/links'; import { links as rulesLinks } from '../../rules/links'; import { links as timelinesLinks } from '../../timelines/links'; import { links as casesLinks } from '../../cases/links'; -import { links as managementLinks, getManagementFilteredLinks } from '../../management/links'; +import { getManagementFilteredLinks, links as managementLinks } from '../../management/links'; import { exploreLinks } from '../../explore/links'; import { onboardingLinks } from '../../onboarding/links'; import { findingsLinks } from '../../cloud_security_posture/links'; @@ -24,6 +24,7 @@ import { dashboardsLinks } from '../../dashboards/links'; export const appLinks: AppLinkItems = Object.freeze([ dashboardsLinks, alertsLink, + alertSummaryLink, attackDiscoveryLinks, findingsLinks, casesLinks, @@ -45,6 +46,7 @@ export const getFilteredLinks = async ( return Object.freeze([ dashboardsLinks, alertsLink, + alertSummaryLink, attackDiscoveryLinks, findingsLinks, casesLinks, diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts index e0b3341bd6273..cf7222b810ca9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/app/translations.ts @@ -105,6 +105,10 @@ export const ALERTS = i18n.translate('xpack.securitySolution.navigation.alerts', defaultMessage: 'Alerts', }); +export const ALERT_SUMMARY = i18n.translate('xpack.securitySolution.navigation.alertSummary', { + defaultMessage: 'Alert summary', +}); + export const ATTACK_DISCOVERY = i18n.translate( 'xpack.securitySolution.navigation.attackDiscovery', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.test.tsx index 44b75ea016c10..21d03153cc4fd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.test.tsx @@ -11,6 +11,10 @@ import React from 'react'; import { ActionableSummary } from '.'; import { TestProviders } from '../../../../../common/mock'; import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { SECURITY_FEATURE_ID } from '../../../../../../common'; + +jest.mock('../../../../../common/lib/kibana'); describe('ActionableSummary', () => { const mockReplacements = { @@ -106,4 +110,37 @@ describe('ActionableSummary', () => { expect(screen.getByTestId('viewInAiAssistantCompact')).toBeInTheDocument(); }); }); + + describe('when configurations capabilities is defined (for AI4DSOC)', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: true, + }, + }, + }, + }, + }); + + render( + + + + ); + }); + + it('renders a disabled badge with the hostname value', () => { + expect(screen.getAllByTestId('disabledActionsBadge')[0]).toHaveTextContent('foo.hostname'); + }); + + it('renders a disabled badge with the username value', () => { + expect(screen.getAllByTestId('disabledActionsBadge')[1]).toHaveTextContent('bar.username'); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.tsx index dd995d115b6c3..5c924812da149 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.tsx @@ -7,12 +7,14 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { - replaceAnonymizedValuesWithOriginalValues, type AttackDiscovery, + replaceAnonymizedValuesWithOriginalValues, type Replacements, } from '@kbn/elastic-assistant-common'; import React, { useMemo } from 'react'; +import { SECURITY_FEATURE_ID } from '../../../../../../common'; +import { useKibana } from '../../../../../common/lib/kibana'; import { AttackDiscoveryMarkdownFormatter } from '../../attack_discovery_markdown_formatter'; import { ViewInAiAssistant } from '../view_in_ai_assistant'; @@ -27,6 +29,17 @@ const ActionableSummaryComponent: React.FC = ({ replacements, showAnonymized = false, }) => { + const { + application: { capabilities }, + } = useKibana().services; + // TODO We shouldn't have to check capabilities here, this should be done at a much higher level. + // https://github.com/elastic/kibana/issues/218731 + // For the AI for SOC we need to hide cell actions and all preview links that could open non-AI4DSOC flyouts + const disabledActions = useMemo( + () => showAnonymized || Boolean(capabilities[SECURITY_FEATURE_ID].configurations), + [capabilities, showAnonymized] + ); + const entitySummary = useMemo( () => showAnonymized @@ -60,7 +73,7 @@ const ActionableSummaryComponent: React.FC = ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.test.tsx new file mode 100644 index 0000000000000..577b3ac343f84 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.test.tsx @@ -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 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'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; + +const dataView: DataView = createStubDataView({ spec: {} }); +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, +]; +const ruleResponse = { + rules: [], + isLoading: false, +}; +const id = 'id'; +const query = { ids: { values: ['abcdef'] } }; + +describe('', () => { + it('should render all components', () => { + const { getByTestId } = render( + +
+ + ); + + expect(getByTestId('internalAlertsPageLoading')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.tsx new file mode 100644 index 0000000000000..5f97d2d2d6f86 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.tsx @@ -0,0 +1,134 @@ +/* + * 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, useRef } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { AlertsTable } from '@kbn/response-ops-alerts-table'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { AlertsTableImperativeApi } from '@kbn/response-ops-alerts-table/types'; +import type { AdditionalTableContext } from '../../../../../../../detections/components/alert_summary/table/table'; +import { + ACTION_COLUMN_WIDTH, + ALERT_TABLE_CONSUMERS, + CASES_CONFIGURATION, + columns, + EuiDataGridStyleWrapper, + GRID_STYLE, + ROW_HEIGHTS_OPTIONS, + RULE_TYPE_IDS, + TOOLBAR_VISIBILITY, +} from '../../../../../../../detections/components/alert_summary/table/table'; +import { ActionsCell } from '../../../../../../../detections/components/alert_summary/table/actions_cell'; +import { getDataViewStateFromIndexFields } from '../../../../../../../common/containers/source/use_data_view'; +import { useKibana } from '../../../../../../../common/lib/kibana'; +import { CellValue } from '../../../../../../../detections/components/alert_summary/table/render_cell'; +import type { RuleResponse } from '../../../../../../../../common/api/detection_engine'; +import { useAdditionalBulkActions } from '../../../../../../../detections/hooks/alert_summary/use_additional_bulk_actions'; + +export interface TableProps { + /** + * DataView created for the alert summary page + */ + dataView: DataView; + /** + * Id to pass down to the ResponseOps alerts table + */ + id: string; + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Query that contains the id of the alerts to display in the table + */ + query: Pick; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +/** + * Component used in the Attack Discovery alerts table, only in the AI4DSOC tier. + * It leverages a lot of configurations and constants from the Alert summary page alerts table, and renders the ResponseOps AlertsTable. + */ +export const Table = memo(({ dataView, id, packages, query, ruleResponse }: TableProps) => { + const { + services: { application, cases, data, fieldFormats, http, licensing, notifications, settings }, + } = useKibana(); + const services = useMemo( + () => ({ + cases, + data, + http, + notifications, + fieldFormats, + application, + licensing, + settings, + }), + [application, cases, data, fieldFormats, http, licensing, notifications, settings] + ); + + const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]); + + const { browserFields } = useMemo( + () => getDataViewStateFromIndexFields('', dataViewSpec.fields), + [dataViewSpec.fields] + ); + + const additionalContext: AdditionalTableContext = useMemo( + () => ({ + packages, + ruleResponse, + }), + [packages, ruleResponse] + ); + + const refetchRef = useRef(null); + const refetch = useCallback(() => { + refetchRef.current?.refresh(); + }, []); + + const bulkActions = useAdditionalBulkActions({ refetch }); + + return ( + + + + ); +}); + +Table.displayName = 'Table'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/wrapper.test.tsx new file mode 100644 index 0000000000000..b2f578928b4a9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/wrapper.test.tsx @@ -0,0 +1,134 @@ +/* + * 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, screen, waitFor } from '@testing-library/react'; +import { AiForSOCAlertsTab, CONTENT_TEST_ID, ERROR_TEST_ID, SKELETON_TEST_ID } from './wrapper'; +import { useKibana } from '../../../../../../../common/lib/kibana'; +import { TestProviders } from '../../../../../../../common/mock'; +import { useFetchIntegrations } from '../../../../../../../detections/hooks/alert_summary/use_fetch_integrations'; +import { useFindRulesQuery } from '../../../../../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; + +jest.mock('./table', () => ({ + Table: () =>
, +})); +jest.mock('../../../../../../../common/lib/kibana'); +jest.mock('../../../../../../../detections/hooks/alert_summary/use_fetch_integrations'); +jest.mock('../../../../../../../detection_engine/rule_management/api/hooks/use_find_rules_query'); + +const id = 'id'; +const query = { ids: { values: ['abcdef'] } }; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: false, + }); + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: [], + isLoading: false, + }); + }); + + it('should render a loading skeleton while creating the dataView', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn(), + clearInstanceCache: jest.fn(), + }, + }, + http: { basePath: { prepend: jest.fn() } }, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render a loading skeleton while fetching packages (integrations)', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn(), + clearInstanceCache: jest.fn(), + }, + }, + http: { basePath: { prepend: jest.fn() } }, + }, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: true, + }); + + render(); + + expect(await screen.findByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render an error if the dataView fail to be created correctly', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn().mockReturnValue(undefined), + clearInstanceCache: jest.fn(), + }, + }, + }, + }); + + jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: jest.fn((f) => f()), + })); + + render(); + + expect(await screen.findByTestId(ERROR_TEST_ID)).toHaveTextContent( + 'Unable to create data view' + ); + }); + + it('should render the content', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest + .fn() + .mockReturnValue({ getIndexPattern: jest.fn(), id: 'id', toSpec: jest.fn() }), + clearInstanceCache: jest.fn(), + }, + query: { filterManager: { getFilters: jest.fn() } }, + }, + }, + }); + + jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: jest.fn((f) => f()), + })); + + render( + + + + ); + + expect(await screen.findByTestId(CONTENT_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/wrapper.tsx new file mode 100644 index 0000000000000..5e2b43cc563f2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/wrapper.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, { memo, useEffect, useMemo, useState } from 'react'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { EuiEmptyPrompt, EuiSkeletonRectangle } from '@elastic/eui'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { i18n } from '@kbn/i18n'; +import { Table } from './table'; +import { useFetchIntegrations } from '../../../../../../../detections/hooks/alert_summary/use_fetch_integrations'; +import { useFindRulesQuery } from '../../../../../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { useKibana } from '../../../../../../../common/lib/kibana'; + +const DATAVIEW_ERROR = i18n.translate( + 'xpack.securitySolution.attackDiscovery.aiForSocTableTab.dataViewError', + { + defaultMessage: 'Unable to create data view', + } +); + +export const ERROR_TEST_ID = 'attack-discovery-alert-error'; +export const SKELETON_TEST_ID = 'attack-discovery-alert-skeleton'; +export const CONTENT_TEST_ID = 'attack-discovery-alert-content'; + +const dataViewSpec: DataViewSpec = { title: '.alerts-security.alerts-default' }; + +interface AiForSOCAlertsTabProps { + /** + * Id to pass down to the ResponseOps alerts table + */ + id: string; + /** + * Query that contains the id of the alerts to display in the table + */ + query: Pick; +} + +/** + * Component used in the Attack Discovery alerts table, only in the AI4DSOC tier. + * It fetches rules, packages (integrations) and creates a local dataView. + * It renders a loading skeleton while packages are being fetched and while the dataView is being created. + */ +export const AiForSOCAlertsTab = memo(({ id, query }: AiForSOCAlertsTabProps) => { + const { data } = useKibana().services; + const [dataView, setDataView] = useState(undefined); + const [dataViewLoading, setDataViewLoading] = useState(true); + + // Fetch all integrations + const { installedPackages, isLoading: integrationIsLoading } = useFetchIntegrations(); + + // 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: ruleData, isLoading: ruleIsLoading } = useFindRulesQuery({}); + const ruleResponse = useMemo( + () => ({ + rules: ruleData?.rules || [], + isLoading: ruleIsLoading, + }), + [ruleData, ruleIsLoading] + ); + + useEffect(() => { + let dv: DataView; + const createDataView = async () => { + try { + dv = await data.dataViews.create(dataViewSpec); + setDataView(dv); + setDataViewLoading(false); + } catch (err) { + setDataViewLoading(false); + } + }; + createDataView(); + + // clearing after leaving the page + return () => { + if (dv?.id) { + data.dataViews.clearInstanceCache(dv.id); + } + }; + }, [data.dataViews]); + + return ( + + <> + {!dataView || !dataView.id ? ( + {DATAVIEW_ERROR}} + /> + ) : ( +
+
+ + )} + + + ); +}); + +AiForSOCAlertsTab.displayName = 'AiForSOCAlertsTab'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx index dd9b3b1189cc4..2c5efbc61ba38 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx @@ -5,23 +5,71 @@ * 2.0. */ -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../../../../common/mock'; import { mockAttackDiscovery } from '../../../../mock/mock_attack_discovery'; import { AlertsTab } from '.'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { SECURITY_FEATURE_ID } from '../../../../../../../common'; + +jest.mock('../../../../../../common/lib/kibana'); +jest.mock('../../../../../../detections/components/alerts_table', () => ({ + DetectionEngineAlertsTable: () =>
, +})); +jest.mock('./ai_for_soc/wrapper', () => ({ + AiForSOCAlertsTab: () =>
, +})); describe('AlertsTab', () => { - it('renders the alerts tab', () => { - render( + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the alerts tab with DetectionEngineAlertsTable', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: false, + }, + }, + }, + }, + }); + + const { getByTestId } = render( ); - const alertsTab = screen.getByTestId('alertsTab'); + expect(getByTestId('alertsTab')).toBeInTheDocument(); + expect(getByTestId('detection-engine-alerts-table')).toBeInTheDocument(); + }); + + it('renders the alerts tab with AI4DSOC alerts table', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: true, + }, + }, + }, + }, + }); + + const { getByTestId } = render( + + + + ); - expect(alertsTab).toBeInTheDocument(); + expect(getByTestId('alertsTab')).toBeInTheDocument(); + expect(getByTestId('ai4dsoc-alerts-table')).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx index a848a04bb6317..bb767cb81a071 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx @@ -10,7 +10,9 @@ import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-commo import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import { TableId } from '@kbn/securitysolution-data-table'; -import { AlertConsumers } from '@kbn/rule-data-utils'; +import { AiForSOCAlertsTab } from './ai_for_soc/wrapper'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { SECURITY_FEATURE_ID } from '../../../../../../../common'; import { DetectionEngineAlertsTable } from '../../../../../../detections/components/alerts_table'; interface Props { @@ -19,6 +21,15 @@ interface Props { } const AlertsTabComponent: React.FC = ({ attackDiscovery, replacements }) => { + const { + application: { capabilities }, + } = useKibana().services; + + // TODO We shouldn't have to check capabilities here, this should be done at a much higher level. + // https://github.com/elastic/kibana/issues/218731 + // For the AI for SOC we need to show the Alert summary page alerts table + const AIForSOC = capabilities[SECURITY_FEATURE_ID].configurations; + const originalAlertIds = useMemo( () => attackDiscovery.alertIds.map((alertId) => @@ -36,16 +47,25 @@ const AlertsTabComponent: React.FC = ({ attackDiscovery, replacements }) [originalAlertIds] ); + const id = useMemo(() => `attack-discovery-alerts-${attackDiscovery.id}`, [attackDiscovery.id]); + return (
- + {AIForSOC ? ( +
+ +
+ ) : ( +
+ +
+ )}
); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx index c6d3d7453a1ed..f143b4722c445 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx @@ -13,6 +13,10 @@ import type { Replacements } from '@kbn/elastic-assistant-common'; import { TestProviders } from '../../../../../../common/mock'; import { mockAttackDiscovery } from '../../../../mock/mock_attack_discovery'; import { ATTACK_CHAIN, DETAILS, SUMMARY } from './translations'; +import { SECURITY_FEATURE_ID } from '../../../../../../../common'; +import { useKibana } from '../../../../../../common/lib/kibana'; + +jest.mock('../../../../../../common/lib/kibana'); describe('AttackDiscoveryTab', () => { const mockReplacements: Replacements = { @@ -44,6 +48,8 @@ describe('AttackDiscoveryTab', () => { expect(summaryMarkdown).toHaveTextContent( 'A multi-stage malware attack was detected on foo.hostname involving bar.username. A suspicious application delivered malware, attempted credential theft, and established persistence.' ); + expect(screen.getAllByTestId('entityButton')[0]).toHaveTextContent('foo.hostname'); + expect(screen.getAllByTestId('entityButton')[1]).toHaveTextContent('bar.username'); }); it('renders the details using the real host and username', () => { @@ -53,6 +59,8 @@ describe('AttackDiscoveryTab', () => { expect(detailsMarkdown).toHaveTextContent( `The following attack progression appears to have occurred on the host foo.hostname involving the user bar.username: A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.` ); + expect(screen.getAllByTestId('entityButton')[0]).toHaveTextContent('foo.hostname'); + expect(screen.getAllByTestId('entityButton')[1]).toHaveTextContent('bar.username'); }); }); @@ -193,4 +201,51 @@ The user Administrator opened a malicious Microsoft Word document (C:\\Program F } ); }); + + describe('when configurations capabilities is defined (for AI4DSOC)', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: true, + }, + }, + }, + }, + }); + + render( + + + + ); + }); + + it('renders the summary with disabled badges using the host and username', () => { + const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter'); + const summaryMarkdown = markdownFormatters[0]; + + expect(summaryMarkdown).toHaveTextContent( + 'A multi-stage malware attack was detected on foo.hostname involving bar.username. A suspicious application delivered malware, attempted credential theft, and established persistence.' + ); + expect(screen.getAllByTestId('disabledActionsBadge')[0]).toHaveTextContent('foo.hostname'); + expect(screen.getAllByTestId('disabledActionsBadge')[1]).toHaveTextContent('bar.username'); + }); + + it('renders the details with disabled badgesusing the host and username', () => { + const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter'); + const detailsMarkdown = markdownFormatters[1]; + + expect(detailsMarkdown).toHaveTextContent( + `The following attack progression appears to have occurred on the host foo.hostname involving the user bar.username: A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.` + ); + expect(screen.getAllByTestId('disabledActionsBadge')[0]).toHaveTextContent('foo.hostname'); + expect(screen.getAllByTestId('disabledActionsBadge')[1]).toHaveTextContent('bar.username'); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx index f048cc5e152c7..a29e7b8747318 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import { replaceAnonymizedValuesWithOriginalValues } from '@kbn/elastic-assistant-common'; import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import { replaceAnonymizedValuesWithOriginalValues } from '@kbn/elastic-assistant-common'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; +import { useKibana } from '../../../../../../common/lib/kibana'; import { AttackChain } from './attack/attack_chain'; import { InvestigateInTimelineButton } from '../../../../../../common/components/event_details/investigate_in_timeline_button'; import { buildAlertsKqlFilter } from '../../../../../../detections/components/alerts_table/actions'; @@ -18,6 +19,7 @@ import { getTacticMetadata } from '../../../../../helpers'; import { AttackDiscoveryMarkdownFormatter } from '../../../attack_discovery_markdown_formatter'; import * as i18n from './translations'; import { ViewInAiAssistant } from '../../view_in_ai_assistant'; +import { SECURITY_FEATURE_ID } from '../../../../../../../common'; const scrollable: React.CSSProperties = { overflowX: 'auto', @@ -35,6 +37,17 @@ const AttackDiscoveryTabComponent: React.FC = ({ replacements, showAnonymized = false, }) => { + const { + application: { capabilities }, + } = useKibana().services; + // TODO We shouldn't have to check capabilities here, this should be done at a much higher level. + // https://github.com/elastic/kibana/issues/218731 + // For the AI for SOC we need to hide cell actions and all preview links that could open non-AI4DSOC flyouts + const disabledActions = useMemo( + () => showAnonymized || Boolean(capabilities[SECURITY_FEATURE_ID].configurations), + [capabilities, showAnonymized] + ); + const { euiTheme } = useEuiTheme(); const { detailsMarkdown, summaryMarkdown } = useMemo(() => attackDiscovery, [attackDiscovery]); @@ -73,7 +86,7 @@ const AttackDiscoveryTabComponent: React.FC = ({
@@ -87,7 +100,7 @@ const AttackDiscoveryTabComponent: React.FC = ({
diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.test.tsx new file mode 100644 index 0000000000000..e9643560992b5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.test.tsx @@ -0,0 +1,56 @@ +/* + * 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'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; + +const dataView: DataView = createStubDataView({ spec: {} }); +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, +]; +const ruleResponse = { + rules: [], + isLoading: false, +}; +const id = 'id'; +const query = { ids: { values: ['abcdef'] } }; +const onLoaded = jest.fn(); + +describe('
', () => { + it('should render all components', () => { + const { getByTestId } = render( + +
+ + ); + + expect(getByTestId('internalAlertsPageLoading')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.tsx new file mode 100644 index 0000000000000..b1e6d2fbcd507 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.tsx @@ -0,0 +1,152 @@ +/* + * 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, useRef } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { AlertsTable } from '@kbn/response-ops-alerts-table'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { Alert } from '@kbn/alerting-types'; +import type { EuiDataGridColumn } from '@elastic/eui'; +import type { AlertsTableImperativeApi } from '@kbn/response-ops-alerts-table/types'; +import type { AdditionalTableContext } from '../../../detections/components/alert_summary/table/table'; +import { + ACTION_COLUMN_WIDTH, + ALERT_TABLE_CONSUMERS, + CASES_CONFIGURATION, + columns, + EuiDataGridStyleWrapper, + GRID_STYLE, + ROW_HEIGHTS_OPTIONS, + RULE_TYPE_IDS, + TOOLBAR_VISIBILITY, +} from '../../../detections/components/alert_summary/table/table'; +import { ActionsCell } from '../../../detections/components/alert_summary/table/actions_cell'; +import { getDataViewStateFromIndexFields } from '../../../common/containers/source/use_data_view'; +import { useKibana } from '../../../common/lib/kibana'; +import { CellValue } from '../../../detections/components/alert_summary/table/render_cell'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; +import { useAdditionalBulkActions } from '../../../detections/hooks/alert_summary/use_additional_bulk_actions'; + +export interface TableProps { + /** + * DataView created for the alert summary page + */ + dataView: DataView; + /** + * Id to pass down to the ResponseOps alerts table + */ + id: string; + /** + * Callback fired when the alerts have been first loaded + */ + onLoaded?: (alerts: Alert[], columns: EuiDataGridColumn[]) => void; + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Query that contains the id of the alerts to display in the table + */ + query: Pick; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +/** + * Component used in the Cases page under Alerts tab, only in the AI4DSOC tier. + * It leverages a lot of configurations and constants from the Alert summary page alerts table, and renders the ResponseOps AlertsTable. + */ +export const Table = memo( + ({ dataView, id, onLoaded, packages, query, ruleResponse }: TableProps) => { + const { + services: { + application, + cases, + data, + fieldFormats, + http, + licensing, + notifications, + settings, + }, + } = useKibana(); + const services = useMemo( + () => ({ + cases, + data, + http, + notifications, + fieldFormats, + application, + licensing, + settings, + }), + [application, cases, data, fieldFormats, http, licensing, notifications, settings] + ); + + const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]); + + const { browserFields } = useMemo( + () => getDataViewStateFromIndexFields('', dataViewSpec.fields), + [dataViewSpec.fields] + ); + + const additionalContext: AdditionalTableContext = useMemo( + () => ({ + packages, + ruleResponse, + }), + [packages, ruleResponse] + ); + + const refetchRef = useRef(null); + const refetch = useCallback(() => { + refetchRef.current?.refresh(); + }, []); + + const bulkActions = useAdditionalBulkActions({ refetch }); + + return ( + + + + ); + } +); + +Table.displayName = 'Table'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/wrapper.test.tsx new file mode 100644 index 0000000000000..7045bbef94474 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/wrapper.test.tsx @@ -0,0 +1,135 @@ +/* + * 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, screen, waitFor } from '@testing-library/react'; +import { AiForSOCAlertsTable, CONTENT_TEST_ID, ERROR_TEST_ID, SKELETON_TEST_ID } from './wrapper'; +import { useKibana } from '../../../common/lib/kibana'; +import { TestProviders } from '../../../common/mock'; +import { useFetchIntegrations } from '../../../detections/hooks/alert_summary/use_fetch_integrations'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; + +jest.mock('./table', () => ({ + Table: () =>
, +})); +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../detections/hooks/alert_summary/use_fetch_integrations'); +jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query'); + +const id = 'id'; +const query = { ids: { values: ['abcdef'] } }; +const onLoaded = jest.fn(); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: false, + }); + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: [], + isLoading: false, + }); + }); + + it('should render a loading skeleton while creating the dataView', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn(), + clearInstanceCache: jest.fn(), + }, + }, + http: { basePath: { prepend: jest.fn() } }, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render a loading skeleton while fetching packages (integrations)', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn(), + clearInstanceCache: jest.fn(), + }, + }, + http: { basePath: { prepend: jest.fn() } }, + }, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: true, + }); + + render(); + + expect(await screen.findByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render an error if the dataView fail to be created correctly', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn().mockReturnValue(undefined), + clearInstanceCache: jest.fn(), + }, + }, + }, + }); + + jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: jest.fn((f) => f()), + })); + + render(); + + expect(await screen.findByTestId(ERROR_TEST_ID)).toHaveTextContent( + 'Unable to create data view' + ); + }); + + it('should render the content', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest + .fn() + .mockReturnValue({ getIndexPattern: jest.fn(), id: 'id', toSpec: jest.fn() }), + clearInstanceCache: jest.fn(), + }, + query: { filterManager: { getFilters: jest.fn() } }, + }, + }, + }); + + jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: jest.fn((f) => f()), + })); + + render( + + + + ); + + expect(await screen.findByTestId(CONTENT_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/wrapper.tsx new file mode 100644 index 0000000000000..c642c60baf65c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/wrapper.tsx @@ -0,0 +1,123 @@ +/* + * 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, useEffect, useMemo, useState } from 'react'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { type EuiDataGridColumn, EuiEmptyPrompt, EuiSkeletonRectangle } from '@elastic/eui'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { i18n } from '@kbn/i18n'; +import type { Alert } from '@kbn/alerting-types'; +import { Table } from './table'; +import { useFetchIntegrations } from '../../../detections/hooks/alert_summary/use_fetch_integrations'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { useKibana } from '../../../common/lib/kibana'; + +const DATAVIEW_ERROR = i18n.translate( + 'xpack.securitySolution.attackDiscovery.aiForSocTableTab.dataViewError', + { + defaultMessage: 'Unable to create data view', + } +); + +export const ERROR_TEST_ID = 'cases-alert-error'; +export const SKELETON_TEST_ID = 'cases-alert-skeleton'; +export const CONTENT_TEST_ID = 'cases-alert-content'; + +const dataViewSpec: DataViewSpec = { title: '.alerts-security.alerts-default' }; + +interface AiForSOCAlertsTableProps { + /** + * Id to pass down to the ResponseOps alerts table + */ + id: string; + /** + * Callback fired when the alerts have been first loaded + */ + onLoaded?: (alerts: Alert[], columns: EuiDataGridColumn[]) => void; + /** + * Query that contains the id of the alerts to display in the table + */ + query: Pick; +} + +/** + * Component used in the Cases page under the Alerts tab, only in the AI4DSOC tier. + * It fetches rules, packages (integrations) and creates a local dataView. + * It renders a loading skeleton while packages are being fetched and while the dataView is being created. + */ +export const AiForSOCAlertsTable = memo(({ id, onLoaded, query }: AiForSOCAlertsTableProps) => { + const { data } = useKibana().services; + const [dataView, setDataView] = useState(undefined); + const [dataViewLoading, setDataViewLoading] = useState(true); + + // Fetch all integrations + const { installedPackages, isLoading: integrationIsLoading } = useFetchIntegrations(); + + // 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: ruleData, isLoading: ruleIsLoading } = useFindRulesQuery({}); + const ruleResponse = useMemo( + () => ({ + rules: ruleData?.rules || [], + isLoading: ruleIsLoading, + }), + [ruleData, ruleIsLoading] + ); + + useEffect(() => { + let dv: DataView; + const createDataView = async () => { + try { + dv = await data.dataViews.create(dataViewSpec); + setDataView(dv); + setDataViewLoading(false); + } catch (err) { + setDataViewLoading(false); + } + }; + createDataView(); + + // clearing after leaving the page + return () => { + if (dv?.id) { + data.dataViews.clearInstanceCache(dv.id); + } + }; + }, [data.dataViews]); + + return ( + + <> + {!dataView || !dataView.id ? ( + {DATAVIEW_ERROR}} + /> + ) : ( +
+
+ + )} + + + ); +}); + +AiForSOCAlertsTable.displayName = 'AiForSOCAlertsTable'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx index 0f7800340a9b9..a4423a2d46d33 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx @@ -10,13 +10,20 @@ import { useDispatch } from 'react-redux'; import type { CaseViewRefreshPropInterface } from '@kbn/cases-plugin/common'; import { CaseMetricsFeature } from '@kbn/cases-plugin/common'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { CaseViewAlertsTableProps } from '@kbn/cases-plugin/public/components/case_view/types'; +import { IOCPanelKey } from '../../flyout/ai_for_soc/constants/panel_keys'; import { DetectionEngineAlertsTable } from '../../detections/components/alerts_table'; import { CaseDetailsRefreshContext } from '../../common/components/endpoint'; import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys'; import { RulePanelKey } from '../../flyout/rule_details/right'; import { TimelineId } from '../../../common/types/timeline'; import { useKibana, useNavigation } from '../../common/lib/kibana'; -import { APP_ID, CASES_PATH, SecurityPageName } from '../../../common/constants'; +import { + APP_ID, + CASES_PATH, + SECURITY_FEATURE_ID, + SecurityPageName, +} from '../../../common/constants'; import { timelineActions } from '../../timelines/store'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { getEndpointDetailsPath } from '../../management/common/routing'; @@ -28,9 +35,14 @@ import { useFetchAlertData } from './use_fetch_alert_data'; import { useUpsellingMessage } from '../../common/hooks/use_upselling'; import { useFetchNotes } from '../../notes/hooks/use_fetch_notes'; import { DocumentEventTypes } from '../../common/lib/telemetry'; +import { AiForSOCAlertsTable } from '../components/ai_for_soc/wrapper'; const CaseContainerComponent: React.FC = () => { - const { cases, telemetry } = useKibana().services; + const { + application: { capabilities }, + cases, + telemetry, + } = useKibana().services; const { getAppUrl, navigateTo } = useNavigation(); const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); const dispatch = useDispatch(); @@ -41,24 +53,53 @@ const CaseContainerComponent: React.FC = () => { const interactionsUpsellingMessage = useUpsellingMessage('investigation_guide_interactions'); + // TODO We shouldn't have to check capabilities here, this should be done at a much higher level. + // https://github.com/elastic/kibana/issues/218741 + const AIForSOC = capabilities[SECURITY_FEATURE_ID].configurations; + const showAlertDetails = useCallback( (alertId: string, index: string) => { - openFlyout({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: alertId, - indexName: index, - scopeId: TimelineId.casePage, + // For the AI for SOC we need to show the AI alert flyout. + if (AIForSOC) { + openFlyout({ + right: { + id: IOCPanelKey, + params: { + id: alertId, + indexName: index, + }, + }, + }); + } else { + openFlyout({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: alertId, + indexName: index, + scopeId: TimelineId.casePage, + }, }, - }, - }); - telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutOpened, { - location: TimelineId.casePage, - panel: 'right', - }); + }); + telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutOpened, { + location: TimelineId.casePage, + panel: 'right', + }); + } + }, + [AIForSOC, openFlyout, telemetry] + ); + + const renderAlertsTable = useCallback( + (props: CaseViewAlertsTableProps) => { + // For the AI for SOC we need to show the Alert summary page alerts table. + if (AIForSOC) { + return ; + } else { + return ; + } }, - [openFlyout, telemetry] + [AIForSOC] ); const onRuleDetailsClick = useCallback( @@ -146,7 +187,7 @@ const CaseContainerComponent: React.FC = () => { useFetchAlertData, onAlertsTableLoaded, permissions: userCasesPermissions, - renderAlertsTable: (props) => , + renderAlertsTable, })} diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.tsx index 792074b39cfeb..c3108a8fcda20 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.tsx @@ -7,7 +7,7 @@ import { set } from '@kbn/safer-lodash-set/fp'; import { getOr } from 'lodash/fp'; -import React, { memo, useEffect, useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useEffect, useMemo } from 'react'; import type { ConnectedProps } from 'react-redux'; import { connect, useDispatch } from 'react-redux'; import type { Dispatch } from 'redux'; @@ -16,14 +16,14 @@ import deepEqual from 'fast-deep-equal'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import type { FilterManager, SavedQuery } from '@kbn/data-plugin/public'; +import type { DataViewSpec } from '@kbn/data-views-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; import type { OnTimeChangeProps } from '@elastic/eui'; -import type { DataViewSpec } from '@kbn/data-views-plugin/public'; import { inputsActions } from '../../store/inputs'; import type { InputsRange } from '../../store/inputs/model'; import type { InputsModelId } from '../../store/inputs/constants'; -import type { State, inputsModel } from '../../store'; +import type { inputsModel, State } from '../../store'; import { formatDate } from '../super_date_picker'; import { endSelector, @@ -51,6 +51,10 @@ interface SiemSearchBarProps { dataTestSubj?: string; hideFilterBar?: boolean; hideQueryInput?: boolean; + /** + * Allows to hide the query menu button displayed to the left of the query input. + */ + hideQueryMenu?: boolean; } export const SearchBarComponent = memo( @@ -60,6 +64,7 @@ export const SearchBarComponent = memo( fromStr, hideFilterBar = false, hideQueryInput = false, + hideQueryMenu = false, id, isLoading = false, pollForSignalIndex, @@ -337,6 +342,7 @@ export const SearchBarComponent = memo( showFilterBar={!hideFilterBar} showDatePicker={true} showQueryInput={!hideQueryInput} + showQueryMenu={!hideQueryMenu} saveQueryMenuVisibility="allowed_by_app_privilege" dataTestSubj={dataTestSubj} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.test.tsx new file mode 100644 index 0000000000000..e9fee215979e2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.test.tsx @@ -0,0 +1,25 @@ +/* + * 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 { useAppToasts } from '../../../hooks/use_app_toasts'; +import { useSetAlertAssignees } from './use_set_alert_assignees'; + +jest.mock('../../../hooks/use_app_toasts'); + +describe('useSetAlertAssignees', () => { + it('should return a function', () => { + (useAppToasts as jest.Mock).mockReturnValue({ + addSuccess: jest.fn(), + addError: jest.fn(), + }); + + const { result } = renderHook(() => useSetAlertAssignees()); + + expect(typeof result.current).toEqual('function'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx index 43630cda420c7..1b6e001a33116 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import type { CoreStart } from '@kbn/core/public'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useCallback, useEffect, useRef } from 'react'; import type { AlertAssignees } from '../../../../../common/api/detection_engine'; import { useAppToasts } from '../../../hooks/use_app_toasts'; @@ -28,14 +26,13 @@ export type ReturnSetAlertAssignees = SetAlertAssigneesFunc | null; * @param ids alert ids that will be used to create the update query. * @param onSuccess a callback function that will be called on successful api response * @param setTableLoading a function that sets the alert table in a loading state for bulk actions - * * @throws An error if response is not OK */ export const useSetAlertAssignees = (): ReturnSetAlertAssignees => { - const { http } = useKibana().services; const { addSuccess, addError } = useAppToasts(); - const setAlertAssigneesRef = useRef(null); + + const abortCtrl = useRef(new AbortController()); const onUpdateSuccess = useCallback( (updated: number = 0) => addSuccess(i18n.UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST(updated)), @@ -49,38 +46,32 @@ export const useSetAlertAssignees = (): ReturnSetAlertAssignees => { [addError] ); - useEffect(() => { - let ignore = false; - const abortCtrl = new AbortController(); - - const onSetAlertAssignees: SetAlertAssigneesFunc = async ( - assignees, - ids, - onSuccess, - setTableLoading - ) => { + const onSetAlertAssignees: SetAlertAssigneesFunc = useCallback( + async (assignees, ids, onSuccess, setTableLoading) => { try { setTableLoading(true); - const response = await setAlertAssignees({ assignees, ids, signal: abortCtrl.signal }); - if (!ignore) { - onSuccess(); - setTableLoading(false); - onUpdateSuccess(response.updated); - } + const response = await setAlertAssignees({ + assignees, + ids, + signal: abortCtrl.current.signal, + }); + onSuccess(); + setTableLoading(false); + onUpdateSuccess(response.updated); } catch (error) { - if (!ignore) { - setTableLoading(false); - onUpdateFailure(error); - } + setTableLoading(false); + onUpdateFailure(error); } - }; + }, + [onUpdateFailure, onUpdateSuccess] + ); - setAlertAssigneesRef.current = onSetAlertAssignees; + useEffect(() => { + const currentAbortCtrl = abortCtrl.current; return (): void => { - ignore = true; - abortCtrl.abort(); + currentAbortCtrl.abort(); }; - }, [http, onUpdateFailure, onUpdateSuccess]); + }, [abortCtrl]); - return setAlertAssigneesRef.current; + return onSetAlertAssignees; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.test.tsx new file mode 100644 index 0000000000000..736d64ed0f415 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.test.tsx @@ -0,0 +1,25 @@ +/* + * 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 { useSetAlertTags } from './use_set_alert_tags'; +import { useAppToasts } from '../../../hooks/use_app_toasts'; + +jest.mock('../../../hooks/use_app_toasts'); + +describe('useSetAlertTags', () => { + it('should return a function', () => { + (useAppToasts as jest.Mock).mockReturnValue({ + addSuccess: jest.fn(), + addError: jest.fn(), + }); + + const { result } = renderHook(() => useSetAlertTags()); + + expect(typeof result.current).toEqual('function'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.tsx index b24719c9dd0d6..bb1dfe2ecb656 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import type { CoreStart } from '@kbn/core/public'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useCallback, useEffect, useRef } from 'react'; import type { AlertTags } from '../../../../../common/api/detection_engine'; import { useAppToasts } from '../../../hooks/use_app_toasts'; @@ -33,9 +31,9 @@ export type ReturnSetAlertTags = SetAlertTagsFunc | null; * @throws An error if response is not OK */ export const useSetAlertTags = (): ReturnSetAlertTags => { - const { http } = useKibana().services; const { addSuccess, addError } = useAppToasts(); - const setAlertTagsRef = useRef(null); + + const abortCtrl = useRef(new AbortController()); const onUpdateSuccess = useCallback( (updated: number = 0) => addSuccess(i18n.UPDATE_ALERT_TAGS_SUCCESS_TOAST(updated)), @@ -49,33 +47,28 @@ export const useSetAlertTags = (): ReturnSetAlertTags => { [addError] ); - useEffect(() => { - let ignore = false; - const abortCtrl = new AbortController(); - - const onSetAlertTags: SetAlertTagsFunc = async (tags, ids, onSuccess, setTableLoading) => { + const onSetAlertTags: SetAlertTagsFunc = useCallback( + async (tags, ids, onSuccess, setTableLoading) => { try { setTableLoading(true); - const response = await setAlertTags({ tags, ids, signal: abortCtrl.signal }); - if (!ignore) { - onSuccess(); - setTableLoading(false); - onUpdateSuccess(response.updated); - } + const response = await setAlertTags({ tags, ids, signal: abortCtrl.current.signal }); + onSuccess(); + setTableLoading(false); + onUpdateSuccess(response.updated); } catch (error) { - if (!ignore) { - setTableLoading(false); - onUpdateFailure(error); - } + setTableLoading(false); + onUpdateFailure(error); } - }; + }, + [onUpdateFailure, onUpdateSuccess] + ); - setAlertTagsRef.current = onSetAlertTags; + useEffect(() => { + const currentAbortCtrl = abortCtrl.current; return (): void => { - ignore = true; - abortCtrl.abort(); + currentAbortCtrl.abort(); }; - }, [http, onUpdateFailure, onUpdateSuccess]); + }, [abortCtrl]); - return setAlertTagsRef.current; + return onSetAlertTags; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/common/integration_icon.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/common/integration_icon.test.tsx new file mode 100644 index 0000000000000..c4e4f6c080660 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/common/integration_icon.test.tsx @@ -0,0 +1,73 @@ +/* + * 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 { render } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import React from 'react'; +import { + INTEGRATION_ICON_TEST_ID, + INTEGRATION_LOADING_SKELETON_TEST_ID, + IntegrationIcon, +} from './integration_icon'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks'; + +jest.mock('@kbn/fleet-plugin/public/hooks'); + +const testId = 'testid'; +const integration: PackageListItem = { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', +}; + +describe('IntegrationIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + }); + + it('should render a single integration icon', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId(`${testId}-${INTEGRATION_ICON_TEST_ID}`)).toBeInTheDocument(); + }); + + it('should render the loading skeleton', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId(`${testId}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`)).toBeInTheDocument(); + }); + + it('should not render skeleton or icon', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId(`${testId}-${INTEGRATION_ICON_TEST_ID}`)).not.toBeInTheDocument(); + expect( + queryByTestId(`${testId}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`) + ).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/common/integration_icon.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/common/integration_icon.tsx new file mode 100644 index 0000000000000..69e1a169b7a12 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/common/integration_icon.tsx @@ -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 React, { memo } from 'react'; +import { EuiSkeletonText } from '@elastic/eui'; +import { CardIcon } from '@kbn/fleet-plugin/public'; +import type { IconSize } from '@elastic/eui/src/components/icon/icon'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; + +export const INTEGRATION_LOADING_SKELETON_TEST_ID = 'integration-loading-skeleton'; +export const INTEGRATION_ICON_TEST_ID = 'integration-icon'; + +interface IntegrationProps { + /** + * Optional data test subject string + */ + 'data-test-subj'?: string; + /** + * Changes the size of the icon. Uses the Eui IconSize interface. + * Defaults to s + */ + iconSize?: IconSize; + /** + * Id of the rule the alert was generated by + */ + integration: PackageListItem | undefined; + /** + * If true, renders a EuiSkeletonText + */ + isLoading?: boolean; +} + +/** + * Renders the icon for the integration. Renders a EuiSkeletonText if loading. + */ +export const IntegrationIcon = memo( + ({ + 'data-test-subj': dataTestSubj, + iconSize = 's', + integration, + isLoading = false, + }: IntegrationProps) => ( + + {integration ? ( + + ) : null} + + ) +); + +IntegrationIcon.displayName = 'IntegrationIcon'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx new file mode 100644 index 0000000000000..49ae302d3782b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { + IntegrationCard, + LAST_ACTIVITY_LOADING_SKELETON_TEST_ID, + LAST_ACTIVITY_VALUE_TEST_ID, +} from './integration_card'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +jest.mock('@kbn/kibana-react-plugin/public'); + +const dataTestSubj = 'test-id'; +const integration: PackageListItem = { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', +}; + +describe('', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { http: { basePath: { prepend: jest.fn() } } }, + }); + }); + + it('should render the card with skeleton while loading last activity', () => { + const { getByTestId, queryByTestId } = render( + + ); + + expect(getByTestId(dataTestSubj)).toHaveTextContent('Splunk'); + expect( + getByTestId(`${dataTestSubj}${LAST_ACTIVITY_LOADING_SKELETON_TEST_ID}`) + ).toBeInTheDocument(); + expect(queryByTestId(`${dataTestSubj}${LAST_ACTIVITY_VALUE_TEST_ID}`)).not.toBeInTheDocument(); + }); + + it('should render the card with last activity value', () => { + const lastActivity = 1735711200000; // Wed Jan 01 2025 00:00:00 GMT-0600 (Central Standard Time) + const { getByTestId, queryByTestId } = render( + + ); + + expect( + queryByTestId(`${dataTestSubj}${LAST_ACTIVITY_LOADING_SKELETON_TEST_ID}`) + ).not.toBeInTheDocument(); + expect(getByTestId(`${dataTestSubj}${LAST_ACTIVITY_VALUE_TEST_ID}`)).toHaveTextContent( + 'Last synced: 2025-01-01T06:00:00Z' + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx new file mode 100644 index 0000000000000..4e62ab1b52ef0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_card.tsx @@ -0,0 +1,116 @@ +/* + * 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 } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSkeletonText, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { IntegrationIcon } from '../common/integration_icon'; +import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; + +const LAST_SYNCED = i18n.translate( + 'xpack.securitySolution.alertSummary.integrations.lastSyncedLabel', + { + defaultMessage: 'Last synced: ', + } +); + +const MIN_WIDTH = 200; + +export const LAST_ACTIVITY_LOADING_SKELETON_TEST_ID = '-last-activity-loading-skeleton'; +export const LAST_ACTIVITY_VALUE_TEST_ID = '-last-activity-value'; + +export interface IntegrationProps { + /** + * Installed AI for SOC integration + */ + integration: PackageListItem; + /** + * True while retrieving data streams to provide the last activity value + */ + isLoading: boolean; + /** + * Timestamp of the last time the integration synced (via data streams) + */ + lastActivity: number | undefined; + /** + * Data test subject string for testing + */ + ['data-test-subj']?: string; +} + +/** + * Rendered on the alert summary page. The card displays the icon, name and last sync value. + */ +export const IntegrationCard = memo( + ({ 'data-test-subj': dataTestSubj, integration, isLoading, lastActivity }: IntegrationProps) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + + + + {integration.title} + + + + + + {LAST_SYNCED} + + + + + + + + + ); + } +); + +IntegrationCard.displayName = 'IntegrationCard'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx new file mode 100644 index 0000000000000..f17f738bf8cbb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { + ADD_INTEGRATIONS_BUTTON_TEST_ID, + CARD_TEST_ID, + IntegrationSection, +} from './integration_section'; +import { useIntegrationsLastActivity } from '../../../hooks/alert_summary/use_integrations_last_activity'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useNavigateToIntegrationsPage } from '../../../hooks/alert_summary/use_navigate_to_integrations_page'; + +jest.mock('../../../hooks/alert_summary/use_navigate_to_integrations_page'); +jest.mock('../../../hooks/alert_summary/use_integrations_last_activity'); +jest.mock('@kbn/kibana-react-plugin/public'); + +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + path: '', + status: installationStatuses.Installed, + title: 'Splunk', + version: '', + }, + { + description: '', + download: '', + id: 'google_secops', + name: 'google_secops', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + path: '', + status: installationStatuses.Installed, + title: 'Google SecOps', + version: '', + }, +]; + +describe('', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: { + basePath: { + prepend: jest.fn().mockReturnValue('/app/integrations/detail/splunk-0.1.0/overview'), + }, + }, + }, + }); + }); + + it('should render a card for each integration ', () => { + (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(jest.fn()); + (useIntegrationsLastActivity as jest.Mock).mockReturnValue({ + isLoading: true, + lastActivities: {}, + }); + + const { getByTestId } = render(); + + expect(getByTestId(`${CARD_TEST_ID}splunk`)).toHaveTextContent('Splunk'); + expect(getByTestId(`${CARD_TEST_ID}google_secops`)).toHaveTextContent('Google SecOps'); + }); + + it('should navigate to the fleet page when clicking on the add integrations button', () => { + const navigateToIntegrationsPage = jest.fn(); + (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(navigateToIntegrationsPage); + (useIntegrationsLastActivity as jest.Mock).mockReturnValue([]); + + const { getByTestId } = render(); + + getByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID).click(); + + expect(navigateToIntegrationsPage).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx new file mode 100644 index 0000000000000..3854b8c961b69 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/integrations/integration_section.tsx @@ -0,0 +1,71 @@ +/* + * 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 } from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { useIntegrationsLastActivity } from '../../../hooks/alert_summary/use_integrations_last_activity'; +import { IntegrationCard } from './integration_card'; +import { useNavigateToIntegrationsPage } from '../../../hooks/alert_summary/use_navigate_to_integrations_page'; + +const ADD_INTEGRATION = i18n.translate( + 'xpack.securitySolution.alertSummary.integrations.addIntegrationButtonLabel', + { + defaultMessage: 'Add integration', + } +); + +export const CARD_TEST_ID = 'alert-summary-integration-card-'; +export const ADD_INTEGRATIONS_BUTTON_TEST_ID = 'alert-summary-add-integrations-button'; + +export interface IntegrationSectionProps { + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; +} + +/** + * Section rendered at the top of the alert summary page. It displays all the AI for SOC installed integrations + * and allow the user to add more integrations by clicking on a button that links to a Fleet page. + * Each integration card is also displaying the last time the sync happened (using streams). + */ +export const IntegrationSection = memo(({ packages }: IntegrationSectionProps) => { + const navigateToIntegrationsPage = useNavigateToIntegrationsPage(); + const { isLoading, lastActivities } = useIntegrationsLastActivity({ packages }); + + return ( + + + + {packages.map((pkg) => ( + + + + ))} + + + + + {ADD_INTEGRATION} + + + + ); +}); + +IntegrationSection.displayName = 'IntegrationSection'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/alerts_progress_bar_by_host_name_panel.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/alerts_progress_bar_by_host_name_panel.test.tsx new file mode 100644 index 0000000000000..b1158019e97c7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/alerts_progress_bar_by_host_name_panel.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 { + ALERTS_BY_HOST_NO_DATA, + ALERTS_BY_HOST_PANEL, + ALERTS_BY_HOST_PROGRESS_BAR, + ALERTS_BY_HOST_ROW, + AlertsProgressBarByHostNamePanel, +} from './alerts_progress_bar_by_host_name_panel'; +import { TestProviders } from '../../../../common/mock'; +import { useSummaryChartData } from '../../alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { parsedAlerts } from '../../alerts_kpis/alerts_progress_bar_panel/mock_data'; + +jest.mock('../../alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render all components', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + items: parsedAlerts, + isLoading: false, + }); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('header-section')).toHaveTextContent('Alert distribution by host'); + expect(getByTestId(ALERTS_BY_HOST_PANEL)).toHaveTextContent('Host name'); + + expect(queryByTestId(ALERTS_BY_HOST_PROGRESS_BAR)).not.toBeInTheDocument(); + expect(queryByTestId(ALERTS_BY_HOST_NO_DATA)).not.toBeInTheDocument(); + + parsedAlerts + .filter((value) => value.key !== '-') + .forEach((alert, i) => { + expect(getByTestId(`${ALERTS_BY_HOST_ROW}${alert.key}`)).toBeInTheDocument(); + expect(getByTestId(`${ALERTS_BY_HOST_ROW}${alert.key}`).textContent).toContain( + parsedAlerts[i].label + ); + expect(getByTestId(`${ALERTS_BY_HOST_ROW}${alert.key}`).textContent).toContain( + parsedAlerts[i].percentageLabel + ); + }); + }); + + it('should render loading', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + items: [], + isLoading: true, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(ALERTS_BY_HOST_PROGRESS_BAR)).toBeInTheDocument(); + }); + + it('should render no data', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + items: [], + isLoading: false, + }); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId(ALERTS_BY_HOST_NO_DATA)).toBeInTheDocument(); + expect(queryByTestId(ALERTS_BY_HOST_PROGRESS_BAR)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/alerts_progress_bar_by_host_name_panel.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/alerts_progress_bar_by_host_name_panel.tsx new file mode 100644 index 0000000000000..ce9860bb3bae7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/alerts_progress_bar_by_host_name_panel.tsx @@ -0,0 +1,131 @@ +/* + * 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 { + EuiHorizontalRule, + EuiPanel, + EuiProgress, + EuiSpacer, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { v4 as uuid } from 'uuid'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { ProgressBarRow } from '../../alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar_row'; +import { EMPTY_DATA_MESSAGE } from '../../alerts_kpis/alerts_progress_bar_panel/translations'; +import type { ChartsPanelProps } from '../../alerts_kpis/alerts_summary_charts_panel/types'; +import { HeaderSection } from '../../../../common/components/header_section'; +import { InspectButtonContainer } from '../../../../common/components/inspect'; +import { useSummaryChartData } from '../../alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { alertsGroupingAggregations } from '../../alerts_kpis/alerts_summary_charts_panel/aggregations'; +import { + getAggregateData, + getIsAlertsProgressBarData, +} from '../../alerts_kpis/alerts_progress_bar_panel/helpers'; + +export const ALERTS_BY_HOST_PANEL = 'alert-summary-alerts-by-host-panel'; +export const ALERTS_BY_HOST_PROGRESS_BAR = 'alert-summary-alerts-by-host-progress-bar'; +export const ALERTS_BY_HOST_ROW = 'alert-summary-alerts-by-host-row-'; +export const ALERTS_BY_HOST_NO_DATA = 'alert-summary-alerts-by-host-no-data'; + +const ALERT_BY_HOST_NAME_TITLE = i18n.translate( + 'xpack.securitySolution.alertSummary.kpiSection.alertByHostNameTitle', + { + defaultMessage: 'Alert distribution by host', + } +); + +const AGGREGATION_FIELD = 'host.name'; +const AGGREGATION_NAME = 'Host name'; +const TOP_ALERTS_CHART_ID = 'alerts-summary-top-alerts'; +const HEIGHT = 160; // px + +/** + * Renders a list showing the percentages of alerts grouped by host.name . + * The component is used in the alerts page in the AI for SOC alert summary page. + */ +export const AlertsProgressBarByHostNamePanel: React.FC = ({ + filters, + query, + signalIndexName, + runtimeMappings, + skip = false, +}) => { + const { euiTheme } = useEuiTheme(); + + const uniqueQueryId = useMemo(() => `${TOP_ALERTS_CHART_ID}-${uuid()}`, []); + const aggregations = useMemo(() => alertsGroupingAggregations(AGGREGATION_FIELD), []); + const { items, isLoading } = useSummaryChartData({ + aggregations, + filters, + query, + signalIndexName, + runtimeMappings, + skip, + uniqueQueryId, + }); + const data = useMemo(() => (getIsAlertsProgressBarData(items) ? items : []), [items]); + const [nonEmpty] = useMemo(() => getAggregateData(data), [data]); + const noData: boolean = useMemo(() => nonEmpty === 0, [nonEmpty]); + + return ( + + + + + {AGGREGATION_NAME} + + {isLoading ? ( + <> + + + + ) : ( + <> + + {noData ? ( + + {EMPTY_DATA_MESSAGE} + + ) : ( +
+ {data + .filter((item) => item.key !== '-') + .map((item) => ( +
+ + +
+ ))} +
+ )} + + )} +
+
+ ); +}; + +AlertsProgressBarByHostNamePanel.displayName = 'AlertsProgressBarByHostNamePanel'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/kpis_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/kpis_section.test.tsx new file mode 100644 index 0000000000000..68b912d24f6fe --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/kpis_section.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 { act, render } from '@testing-library/react'; +import { KPIsSection } from './kpis_section'; +import { ALERTS_BY_HOST_PANEL } from './alerts_progress_bar_by_host_name_panel'; +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 { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; + +jest.mock('../../../../common/hooks/use_selector'); + +const dataView: DataView = createStubDataView({ spec: {} }); + +describe('', () => { + it('should render all components', async () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ + meta: {}, + }); + + await act(async () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('severty-level-panel')).toBeInTheDocument(); + expect(getByTestId('alerts-by-rule-panel')).toBeInTheDocument(); + expect(getByTestId(ALERTS_BY_HOST_PANEL)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/kpis_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/kpis_section.tsx new file mode 100644 index 0000000000000..c752ffd0c30e1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/kpis/kpis_section.tsx @@ -0,0 +1,70 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { inputsSelectors } from '../../../../common/store'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { SeverityLevelPanel } from '../../alerts_kpis/severity_level_panel'; +import { AlertsByRulePanel } from '../../alerts_kpis/alerts_by_rule_panel'; +import { AlertsProgressBarByHostNamePanel } from './alerts_progress_bar_by_host_name_panel'; + +export const KPIS_SECTION = 'alert-summary-kpis-section'; + +export interface KPIsSectionProps { + /** + * DataView created for the alert summary page + */ + dataView: DataView; +} + +/** + * Section rendering 3 charts in the alert summary page. + * The component leverages existing chart components from the alerts page but is making a few tweaks: + * - the SeverityLevelPanel and AlertsByRulePanel are used directly from the alerts page + * - the UI differences on the AlertsProgressBarPanel were significant enough that a separate component was created + */ +export const KPIsSection = memo(({ dataView }: KPIsSectionProps) => { + const signalIndexName = dataView.getIndexPattern(); + + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + + const getGlobalFiltersSelector = useMemo(() => inputsSelectors.globalFiltersQuerySelector(), []); + const filters = useDeepEqualSelector(getGlobalFiltersSelector); + + return ( + + + + + + + + + + + + ); +}); + +KPIsSection.displayName = 'KPIsSection'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/alert_summary.png b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/alert_summary.png new file mode 100644 index 0000000000000..e4b15f9bbe8ac Binary files /dev/null and b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/alert_summary.png differ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/integration_card.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/integration_card.test.tsx new file mode 100644 index 0000000000000..f6529c50acc6c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/integration_card.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { IntegrationCard } from './integration_card'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +jest.mock('@kbn/kibana-react-plugin/public'); + +const dataTestSubj = 'test-id'; +const integration: PackageListItem = { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', +}; + +describe('', () => { + it('should render the card and navigate to the integration details page', () => { + const navigateToApp = jest.fn(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { navigateToApp }, + http: { + basePath: { + prepend: jest.fn().mockReturnValue('/app/integrations/detail/splunk-0.1.0/overview'), + }, + }, + }, + }); + + const { getByTestId } = render( + + ); + + const card = getByTestId(dataTestSubj); + + expect(card).toHaveTextContent('Splunk'); + expect(card).toHaveTextContent('SIEM'); + + card.click(); + + expect(navigateToApp).toHaveBeenCalledWith('integrations', { + path: '/detail/splunk-0.1.0/overview', + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/integration_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/integration_card.tsx new file mode 100644 index 0000000000000..cbc041f339165 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/integration_card.tsx @@ -0,0 +1,89 @@ +/* + * 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 { css } from '@emotion/react'; +import { EuiBadge, EuiCard, useEuiTheme } from '@elastic/eui'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { INTEGRATIONS_PLUGIN_ID } from '@kbn/fleet-plugin/common'; +import { useLink } from '@kbn/fleet-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { IntegrationIcon } from '../common/integration_icon'; +import { useKibana } from '../../../../common/lib/kibana'; + +const SIEM_BADGE = i18n.translate('xpack.securitySolution.alertSummary.integrations.siemBadge', { + defaultMessage: 'SIEM', +}); + +const MIN_WIDTH = 275; // px +const INTEGRATIONS_BASE_PATH = '/app/integrations'; +const INTEGRATION_DETAILS_PAGE = 'integration_details_overview'; + +export interface IntegrationCardProps { + /** + * AI for SOC integration available to install + */ + integration: PackageListItem; + /** + * Data test subject string for testing + */ + ['data-test-subj']?: string; +} + +/** + * Rendered on the alert summary landing page, when no integrations have been installed. + * The card is clickable and will navigate the user to the integration's details page. + */ +export const IntegrationCard = memo( + ({ integration, 'data-test-subj': dataTestSubj }: IntegrationCardProps) => { + const { euiTheme } = useEuiTheme(); + const iconStyle = useMemo(() => ({ marginInlineEnd: euiTheme.size.base }), [euiTheme]); + + const { + services: { application }, + } = useKibana(); + const { getHref } = useLink(); + + const onClick = useCallback(() => { + const url = getHref(INTEGRATION_DETAILS_PAGE, { + pkgkey: `${integration.name}-${integration.version}`, + ...(integration.integration ? { integration: integration.integration } : {}), + }); + + application.navigateToApp(INTEGRATIONS_PLUGIN_ID, { + path: url.slice(INTEGRATIONS_BASE_PATH.length), + }); + }, [application, getHref, integration.integration, integration.name, integration.version]); + + return ( + {SIEM_BADGE}} + display="plain" + hasBorder + icon={ +
+ +
+ } + layout="horizontal" + onClick={onClick} + titleSize="xs" + title={integration.title} + /> + ); + } +); + +IntegrationCard.displayName = 'IntegrationCard'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/landing_page.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/landing_page.test.tsx new file mode 100644 index 0000000000000..9da83e0844e21 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/landing_page.test.tsx @@ -0,0 +1,85 @@ +/* + * 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 { + LANDING_PAGE_CARD_TEST_ID, + LANDING_PAGE_IMAGE_TEST_ID, + LANDING_PAGE_PROMPT_TEST_ID, + LANDING_PAGE_VIEW_ALL_INTEGRATIONS_BUTTON_TEST_ID, + LandingPage, +} from './landing_page'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { useNavigateToIntegrationsPage } from '../../../hooks/alert_summary/use_navigate_to_integrations_page'; + +jest.mock('../../../hooks/alert_summary/use_navigate_to_integrations_page'); + +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '', + }, + { + description: '', + download: '', + id: 'google_secops', + name: 'google_secops', + path: '', + status: installationStatuses.NotInstalled, + title: 'Google SecOps', + version: '', + }, + { + description: '', + download: '', + id: 'unknown', + name: 'unknown', + path: '', + status: installationStatuses.NotInstalled, + title: 'Unknown', + version: '', + }, +]; + +describe('', () => { + it('should render all the components', () => { + (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(jest.fn()); + + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId(LANDING_PAGE_PROMPT_TEST_ID)).toHaveTextContent( + 'All your alerts in one place with AI' + ); + expect(getByTestId(LANDING_PAGE_PROMPT_TEST_ID)).toHaveTextContent( + 'Bring in your SIEM data to begin surfacing alerts' + ); + + expect(getByTestId(LANDING_PAGE_IMAGE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(`${LANDING_PAGE_CARD_TEST_ID}splunk`)).toBeInTheDocument(); + expect(getByTestId(`${LANDING_PAGE_CARD_TEST_ID}google_secops`)).toBeInTheDocument(); + expect(queryByTestId(`${LANDING_PAGE_CARD_TEST_ID}unknown`)).not.toBeInTheDocument(); + expect(getByTestId(LANDING_PAGE_VIEW_ALL_INTEGRATIONS_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should navigate to the fleet page when clicking on the more integrations button', () => { + const navigateToIntegrationsPage = jest.fn(); + (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(navigateToIntegrationsPage); + + const { getByTestId } = render(); + + getByTestId(LANDING_PAGE_VIEW_ALL_INTEGRATIONS_BUTTON_TEST_ID).click(); + expect(navigateToIntegrationsPage).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/landing_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/landing_page.tsx new file mode 100644 index 0000000000000..0747e3fe63c98 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/landing_page/landing_page.tsx @@ -0,0 +1,152 @@ +/* + * 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 { css } from '@emotion/react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiSpacer, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { IntegrationCard } from './integration_card'; +import imageSrc from './alert_summary.png'; +import { useNavigateToIntegrationsPage } from '../../../hooks/alert_summary/use_navigate_to_integrations_page'; + +const TITLE = i18n.translate('xpack.securitySolution.alertSummary.landingPage.title', { + defaultMessage: 'All your alerts in one place with AI', +}); +const SUB_TITLE = i18n.translate('xpack.securitySolution.alertSummary.landingPage.subTitle', { + defaultMessage: 'Bring in your SIEM data to begin surfacing alerts', +}); +const DATA_TITLE = i18n.translate('xpack.securitySolution.alertSummary.landingPage.dataTitle', { + defaultMessage: 'Start by connecting your data', +}); +const VIEW_ALL_INTEGRATIONS = i18n.translate( + 'xpack.securitySolution.alertSummary.landingPage.viewAllIntegrationsButtonLabel', + { + defaultMessage: 'View all integrations', + } +); + +const PRIMARY_INTEGRATIONS = ['splunk', 'google_secops']; + +export const LANDING_PAGE_PROMPT_TEST_ID = 'alert-summary-landing-page-prompt'; +export const LANDING_PAGE_IMAGE_TEST_ID = 'alert-summary-landing-page-image'; +export const LANDING_PAGE_CARD_TEST_ID = 'alert-summary-landing-page-card-'; +export const LANDING_PAGE_VIEW_ALL_INTEGRATIONS_BUTTON_TEST_ID = + 'alert-summary-landing-page-view-all-integrations-button'; + +export interface LandingPageProps { + /** + * List of available AI for SOC integrations + */ + packages: PackageListItem[]; +} + +/** + * Displays a gif of the alerts summary page, with empty prompt showing the top 2 available AI for SOC packages. + * This page is rendered when no AI for SOC packages are installed. + */ +export const LandingPage = memo(({ packages }: LandingPageProps) => { + const { euiTheme } = useEuiTheme(); + const navigateToIntegrationsPage = useNavigateToIntegrationsPage(); + + // We only want to show the 2 top integrations, Splunk and GoogleSecOps, in that specific order + const primaryPackages = useMemo( + () => + packages + .filter((pkg) => PRIMARY_INTEGRATIONS.includes(pkg.name)) + .sort( + (a, b) => PRIMARY_INTEGRATIONS.indexOf(a.name) - PRIMARY_INTEGRATIONS.indexOf(b.name) + ), + [packages] + ); + + return ( + + + + + + + + +

{TITLE}

+
+
+ + {SUB_TITLE} + + + + + + + +
+
+ + + + +

{DATA_TITLE}

+
+
+ + + {primaryPackages.map((pkg) => ( + + + + ))} + + + + + {VIEW_ALL_INTEGRATIONS} + + +
+
+
+ ); +}); + +LandingPage.displayName = 'LandingPage'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/integrations_filter_button.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/integrations_filter_button.test.tsx new file mode 100644 index 0000000000000..fb4f97b0f5bfd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/integrations_filter_button.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { act, render } from '@testing-library/react'; +import { useKibana } from '../../../../common/lib/kibana'; +import { + FILTER_KEY, + INTEGRATION_BUTTON_TEST_ID, + IntegrationFilterButton, + INTEGRATIONS_LIST_TEST_ID, +} from './integrations_filter_button'; +import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; + +jest.mock('../../../../common/lib/kibana'); + +const integrations: EuiSelectableOption[] = [ + { + 'data-test-subj': 'first', + checked: 'on', + key: 'firstKey', + label: 'firstLabel', + }, + { + 'data-test-subj': 'second', + key: 'secondKey', + label: 'secondLabel', + }, +]; + +describe('', () => { + it('should render the component', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { data: { query: { filterManager: jest.fn() } } }, + }); + + await act(async () => { + const { getByTestId } = render(); + + const button = getByTestId(INTEGRATION_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + button.click(); + + await new Promise(process.nextTick); + + expect(getByTestId(INTEGRATIONS_LIST_TEST_ID)).toBeInTheDocument(); + + expect(getByTestId('first')).toHaveTextContent('firstLabel'); + expect(getByTestId('second')).toHaveTextContent('secondLabel'); + }); + }); + + it('should add a negated filter to filterManager', async () => { + const getFilters = jest.fn().mockReturnValue([]); + const setFilters = jest.fn(); + (useKibana as jest.Mock).mockReturnValue({ + services: { data: { query: { filterManager: { getFilters, setFilters } } } }, + }); + + await act(async () => { + const { getByTestId } = render(); + + getByTestId(INTEGRATION_BUTTON_TEST_ID).click(); + + await new Promise(process.nextTick); + + getByTestId('first').click(); + expect(setFilters).toHaveBeenCalledWith([ + { + meta: { + alias: null, + disabled: false, + index: undefined, + key: FILTER_KEY, + negate: true, + params: { query: 'firstKey' }, + type: 'phrase', + }, + query: { match_phrase: { [FILTER_KEY]: 'firstKey' } }, + }, + ]); + }); + }); + + it('should remove the negated filter from filterManager', async () => { + const getFilters = jest.fn().mockReturnValue([ + { + meta: { + alias: null, + disabled: false, + index: undefined, + key: FILTER_KEY, + negate: true, + params: { query: 'secondKey' }, + type: 'phrase', + }, + query: { match_phrase: { [FILTER_KEY]: 'secondKey' } }, + }, + ]); + const setFilters = jest.fn(); + (useKibana as jest.Mock).mockReturnValue({ + services: { data: { query: { filterManager: { getFilters, setFilters } } } }, + }); + + await act(async () => { + const { getByTestId } = render(); + + getByTestId(INTEGRATION_BUTTON_TEST_ID).click(); + + await new Promise(process.nextTick); + + // creates a new filter that + getByTestId('second').click(); + expect(setFilters).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/integrations_filter_button.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/integrations_filter_button.tsx new file mode 100644 index 0000000000000..b36dc326a1ddd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/integrations_filter_button.tsx @@ -0,0 +1,135 @@ +/* + * 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, useState } from 'react'; +import { css } from '@emotion/react'; +import { + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiSelectable, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; +import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable'; +import type { Filter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { updateFiltersArray } from '../../../utils/filter'; +import { useKibana } from '../../../../common/lib/kibana'; + +export const INTEGRATION_BUTTON_TEST_ID = 'alert-summary-integration-button'; +export const INTEGRATIONS_LIST_TEST_ID = 'alert-summary-integrations-list'; + +const INTEGRATIONS_BUTTON = i18n.translate( + 'xpack.securitySolution.alertSummary.integrations.buttonLabel', + { + defaultMessage: 'Integrations', + } +); + +export const FILTER_KEY = 'signal.rule.id'; + +export interface IntegrationFilterButtonProps { + /** + * List of integrations the user can select or deselect + */ + integrations: EuiSelectableOption[]; +} + +/** + * Filter button displayed next to the KQL bar at the top of the alert summary page. + * For the AI for SOC effort, each integration has one rule associated with. + * This means that deselecting an integration is equivalent to filtering out by the rule for that integration. + * The EuiFilterButton works as follow: + * - if an integration is selected, this means that no filters live in filterManager + * - if an integration is deselected, this means that we have a negated filter for that rule in filterManager + */ +export const IntegrationFilterButton = memo(({ integrations }: IntegrationFilterButtonProps) => { + const { euiTheme } = useEuiTheme(); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); + + const { + data: { + query: { filterManager }, + }, + } = useKibana().services; + + const filterGroupPopoverId = useGeneratedHtmlId({ + prefix: 'filterGroupPopover', + }); + + const [items, setItems] = useState(integrations); + + const onChange = useCallback( + ( + options: EuiSelectableOption[], + _: EuiSelectableOnChangeEvent, + changedOption: EuiSelectableOption + ) => { + setItems(options); + + const ruleId = changedOption.key; + if (ruleId) { + const existingFilters = filterManager.getFilters(); + const newFilters: Filter[] = updateFiltersArray( + existingFilters, + FILTER_KEY, + ruleId, + changedOption.checked === 'on' + ); + filterManager.setFilters(newFilters); + } + }, + [filterManager] + ); + + const button = ( + item.checked === 'on')} + iconType="arrowDown" + isSelected={isPopoverOpen} + numActiveFilters={items.filter((item) => item.checked === 'on').length} + numFilters={items.filter((item) => item.checked !== 'off').length} + onClick={togglePopover} + > + {INTEGRATIONS_BUTTON} + + ); + + return ( + + + + {(list) => list} + + + + ); +}); + +IntegrationFilterButton.displayName = 'IntegrationFilterButton'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/search_bar_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/search_bar_section.test.tsx new file mode 100644 index 0000000000000..a154409862661 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/search_bar_section.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { + INTEGRATION_BUTTON_LOADING_TEST_ID, + SEARCH_BAR_TEST_ID, + SearchBarSection, +} from './search_bar_section'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; +import { INTEGRATION_BUTTON_TEST_ID } from './integrations_filter_button'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useIntegrations } from '../../../hooks/alert_summary/use_integrations'; + +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('../../../../common/lib/kibana'); +jest.mock('../../../hooks/alert_summary/use_integrations'); + +const dataView: DataView = createStubDataView({ spec: {} }); +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + path: '', + status: installationStatuses.Installed, + title: 'Splunk', + version: '', + }, +]; +const ruleResponse = { + rules: [], + isLoading: false, +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render all components', () => { + (useIntegrations as jest.Mock).mockReturnValue({ + isLoading: false, + integrations: [], + }); + (useKibana as jest.Mock).mockReturnValue({ + services: { data: { query: { filterManager: jest.fn() } } }, + }); + + const { getByTestId, queryByTestId } = render( + + ); + + expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(INTEGRATION_BUTTON_LOADING_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(INTEGRATION_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render a loading skeleton for the integration button while fetching rules', () => { + (useIntegrations as jest.Mock).mockReturnValue({ + isLoading: true, + integrations: [], + }); + + const { getByTestId, queryByTestId } = render( + + ); + + expect(getByTestId(INTEGRATION_BUTTON_LOADING_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(INTEGRATION_BUTTON_TEST_ID)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/search_bar_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/search_bar_section.tsx new file mode 100644 index 0000000000000..f4c2491d58bb8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/search_bar/search_bar_section.tsx @@ -0,0 +1,87 @@ +/* + * 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 { DataView } from '@kbn/data-views-plugin/common'; +import { EuiFlexGroup, EuiFlexItem, EuiSkeletonRectangle } from '@elastic/eui'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import type { RuleResponse } from '../../../../../common/api/detection_engine'; +import { useIntegrations } from '../../../hooks/alert_summary/use_integrations'; +import { SiemSearchBar } from '../../../../common/components/search_bar'; +import { IntegrationFilterButton } from './integrations_filter_button'; +import { InputsModelId } from '../../../../common/store/inputs/constants'; + +export const INTEGRATION_BUTTON_LOADING_TEST_ID = 'alert-summary-integration-button-loading'; +export const SEARCH_BAR_TEST_ID = 'alert-summary-search-bar'; + +const INTEGRATION_BUTTON_LOADING_WIDTH = '120px'; +const INTEGRATION_BUTTON_LOADING_HEIGHT = '40px'; + +export interface SearchBarSectionProps { + /** + * DataView created for the alert summary page + */ + dataView: DataView; + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +/** + * KQL bar at the top of the alert summary page. + * The component leverages the Security Solution SiemSearchBar which has a lot of logic tied to url and redux to store its values. + * The component also has a filter button to the left of the KQL bar that allows user to select integrations. + * For the AI for SOC effort, each integration has one rule associated with. + * This means that deselecting an integration is equivalent to filtering out by the rule for that integration. + */ +export const SearchBarSection = memo( + ({ dataView, packages, ruleResponse }: SearchBarSectionProps) => { + const { isLoading, integrations } = useIntegrations({ packages, ruleResponse }); + + const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]); + + return ( + + + + + + + + + + + ); + } +); + +SearchBarSection.displayName = 'SearchBarSection'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.test.tsx new file mode 100644 index 0000000000000..3dac7381cb395 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 { ActionsCell } from './actions_cell'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { MORE_ACTIONS_BUTTON_TEST_ID } from './more_actions_row_control_column'; +import { useAddToCaseActions } from '../../alerts_table/timeline_actions/use_add_to_case_actions'; +import { useAlertTagsActions } from '../../alerts_table/timeline_actions/use_alert_tags_actions'; +import { ROW_ACTION_FLYOUT_ICON_TEST_ID } from './open_flyout_row_control_column'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../alerts_table/timeline_actions/use_add_to_case_actions'); +jest.mock('../../alerts_table/timeline_actions/use_alert_tags_actions'); + +describe('ActionsCell', () => { + it('should render icons', () => { + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ + openFlyout: jest.fn(), + }); + (useAddToCaseActions as jest.Mock).mockReturnValue({ + addToCaseActionItems: [], + }); + (useAlertTagsActions as jest.Mock).mockReturnValue({ + alertTagsItems: [], + alertTagsPanels: [], + }); + + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + const ecsAlert: Ecs = { + _id: '_id', + _index: '_index', + }; + + const { getByTestId } = render(); + + expect(getByTestId(ROW_ACTION_FLYOUT_ICON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(MORE_ACTIONS_BUTTON_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.tsx new file mode 100644 index 0000000000000..456e0e64f5bf6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.tsx @@ -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 React, { type ComponentProps, memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { GetTableProp } from './types'; +import { MoreActionsRowControlColumn } from './more_actions_row_control_column'; +import { OpenFlyoutRowControlColumn } from './open_flyout_row_control_column'; + +export type ActionsCellProps = Pick< + ComponentProps>, + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + | 'alert' + /** + * The Ecs type is @deprecated but needed for the case actions within the more action dropdown + */ + | 'ecsAlert' +>; + +/** + * Component used in the AI for SOC alert summary table. + * It is passed to the renderActionsCell property of the EuiDataGrid. + * It renders all the icons in the row action icons: + * - open flyout + * - assistant + * - more actions + */ +export const ActionsCell = memo(({ alert, ecsAlert }: ActionsCellProps) => ( + + + + + + + + +)); + +ActionsCell.displayName = 'ActionsCell'; 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/basic_cell_renderer.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/basic_cell_renderer.test.tsx new file mode 100644 index 0000000000000..2cd2f592370f9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/basic_cell_renderer.test.tsx @@ -0,0 +1,154 @@ +/* + * 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, screen } from '@testing-library/react'; +import type { Alert } from '@kbn/alerting-types'; +import { BASIC_CELL_RENDERER_TRUNCATE_TEST_ID, BasicCellRenderer } from './basic_cell_renderer'; +import { TestProviders } from '../../../../common/mock'; +import { getEmptyValue } from '../../../../common/components/empty_value'; + +describe('BasicCellRenderer', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field'; + + const { getByText } = render( + + + + ); + + expect(getByText(getEmptyValue())).toBeInTheDocument(); + }); + + it('should handle string value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('value1')).toBeInTheDocument(); + }); + + it('should handle number value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 123, + }; + const columnId = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('123')).toBeInTheDocument(); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const field = '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 field = '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 field = '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 field = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('[object Object]')).toBeInTheDocument(); + }); + + it('should truncate long values and show tooltip', async () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field1'; + + render( + + + + ); + + const cell = screen.getByTestId(BASIC_CELL_RENDERER_TRUNCATE_TEST_ID); + + expect(cell).toBeInTheDocument(); + expect(cell.firstChild).toHaveClass('euiToolTipAnchor'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/basic_cell_renderer.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/basic_cell_renderer.tsx new file mode 100644 index 0000000000000..5e288a3ab2d82 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/basic_cell_renderer.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { getOrEmptyTagFromValue } from '../../../../common/components/empty_value'; +import { TruncatableText } from '../../../../common/components/truncatable_text'; +import { getAlertFieldValueAsStringOrNull } from '../../../utils/type_utils'; + +export const BASIC_CELL_RENDERER_TRUNCATE_TEST_ID = + 'alert-summary-table-basic-call-rendered-truncate'; + +export interface BasicCellRendererProps { + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + alert: Alert; + /** + * Column id passed from the renderCellValue callback via EuiDataGridProps['renderCellValue'] interface + */ + field: string; +} + +/** + * Renders all the basic table cell values. + * Component used in the AI for SOC alert summary table. + */ +export const BasicCellRenderer = memo(({ alert, field }: BasicCellRendererProps) => { + const displayValue: string | null = useMemo( + () => getAlertFieldValueAsStringOrNull(alert, field), + [alert, field] + ); + + return ( + + + + {field} + + + {displayValue} + + + } + > + {getOrEmptyTagFromValue(displayValue)} + + + ); +}); + +BasicCellRenderer.displayName = 'BasicCellRenderer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/datetime_schema_cell_renderer.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/datetime_schema_cell_renderer.test.tsx new file mode 100644 index 0000000000000..39991045b0406 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/datetime_schema_cell_renderer.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { TestProviders } from '../../../../common/mock'; +import { getEmptyValue } from '../../../../common/components/empty_value'; +import { DatetimeSchemaCellRenderer } from './datetime_schema_cell_renderer'; + +describe('DatetimeSchemaCellRenderer', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field'; + + const { getByText } = render( + + + + ); + + expect(getByText(getEmptyValue())).toBeInTheDocument(); + }); + + it('should handle string value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('value1')).toBeInTheDocument(); + }); + + it('should handle number value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 123, + }; + const columnId = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('Jan 1, 1970 @ 00:00:00.123')).toBeInTheDocument(); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const field = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('true')).toBeInTheDocument(); + }); + + it('should handle array of numbers', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [1, 2], + }; + const field = 'field1'; + + const { getByText } = render( + + + + ); + + expect(getByText('Jan 1, 1970 @ 00:00:00.001')).toBeInTheDocument(); + }); + + it('should handle array of null', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [null, null], + }; + const field = '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 field = '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/datetime_schema_cell_renderer.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/datetime_schema_cell_renderer.tsx new file mode 100644 index 0000000000000..cab59e417be1b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/datetime_schema_cell_renderer.tsx @@ -0,0 +1,39 @@ +/* + * 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 { FormattedDate } from '../../../../common/components/formatted_date'; +import { getAlertFieldValueAsStringOrNumberOrNull } from '../../../utils/type_utils'; + +export interface DatetimeSchemaCellRendererProps { + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + alert: Alert; + /** + * Column id passed from the renderCellValue callback via EuiDataGridProps['renderCellValue'] interface + */ + field: string; +} + +/** + * Renders the value of a field of type date (when the schema is 'datetime'). + * Component used in the AI for SOC alert summary table. + */ +export const DatetimeSchemaCellRenderer = memo( + ({ alert, field }: DatetimeSchemaCellRendererProps) => { + const displayValue: number | string | null = useMemo( + () => getAlertFieldValueAsStringOrNumberOrNull(alert, field), + [alert, field] + ); + + return ; + } +); + +DatetimeSchemaCellRenderer.displayName = 'DatetimeSchemaCellRenderer'; 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..8a3ba99e81f12 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.test.tsx @@ -0,0 +1,280 @@ +/* + * 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 { + getIntegrationComponent, + groupStatsRenderer, + IntegrationIcon, + TABLE_GROUP_STATS_TEST_ID, +} from './group_stats_renderers'; +import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id'; +import { useTableSectionContext } from './table_section_context'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks'; +import { INTEGRATION_ICON_TEST_ID } from '../common/integration_icon'; + +jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id'); +jest.mock('@kbn/fleet-plugin/public/hooks'); +jest.mock('./table_section_context'); + +const integration: PackageListItem = { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', +}; + +describe('getIntegrationComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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('IntegrationIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render integration icon', () => { + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + (useTableSectionContext as jest.Mock).mockReturnValue({ + packages: [], + ruleResponse: {}, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration, + }); + + const { getByTestId } = render(); + + expect( + getByTestId(`${TABLE_GROUP_STATS_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`) + ).toBeInTheDocument(); + }); + + it('should not render icon', () => { + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + (useTableSectionContext as jest.Mock).mockReturnValue({ + packages: [], + ruleResponse: {}, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: undefined, + }); + + const { queryByTestId } = render(); + + expect( + queryByTestId(`${TABLE_GROUP_STATS_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`) + ).not.toBeInTheDocument(); + }); +}); + +describe('groupStatsRenderer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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..51a5a46b290ab --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_stats_renderers.tsx @@ -0,0 +1,118 @@ +/* + * 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 { GroupStatsItem, RawBucket } from '@kbn/grouping'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { IntegrationIcon as Icon } from '../common/integration_icon'; +import { useTableSectionContext } from './table_section_context'; +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'; +import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id'; + +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 TABLE_GROUP_STATS_TEST_ID = 'ai-for-soc-alert-table-group-stats'; + +interface IntegrationProps { + /** + * Id of the rule the alert was generated by + */ + ruleId: string; +} + +/** + * 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 IntegrationIcon = memo(({ ruleId }: IntegrationProps) => { + const { packages, ruleResponse } = useTableSectionContext(); + const { integration } = useGetIntegrationFromRuleId({ + packages, + rules: ruleResponse.rules, + ruleId, + }); + + return ; +}); + +IntegrationIcon.displayName = 'IntegrationIcon'; + +/** + * 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) { + const ruleId = Array.isArray(signalRuleIds[0].key) + ? signalRuleIds[0].key[0] + : signalRuleIds[0].key; + 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..f510de42c04c9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.test.tsx @@ -0,0 +1,203 @@ +/* + * 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'; +import { useTableSectionContext } from './table_section_context'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks'; + +jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id'); +jest.mock('./table_section_context'); +jest.mock('@kbn/fleet-plugin/public/hooks'); + +const integration: PackageListItem = { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', +}; + +describe('groupTitleRenderers', () => { + beforeEach(() => { + jest.clearAllMocks(); + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + }); + + it('should render correctly for signal.rule.id field', () => { + (useTableSectionContext as jest.Mock).mockReturnValue({ + packages: [], + ruleResponse: { isLoading: false }, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ integration }); + + 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', () => { + beforeEach(() => { + jest.clearAllMocks(); + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + }); + + it('should render the integration name and icon when a matching rule is found', () => { + (useTableSectionContext as jest.Mock).mockReturnValue({ + packages: [], + ruleResponse: { isLoading: false }, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: { title: 'rule_name', icons: 'icon' }, + }); + + 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', () => { + (useTableSectionContext as jest.Mock).mockReturnValue({ + packages: [], + ruleResponse: { isLoading: false }, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: undefined, + }); + + 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', () => { + (useTableSectionContext as jest.Mock).mockReturnValue({ + packages: [], + ruleResponse: { isLoading: true }, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: undefined, + }); + + 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..9f69e5cb28f3e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/group_title_renderers.tsx @@ -0,0 +1,136 @@ +/* + * 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 { IntegrationIcon } from '../common/integration_icon'; +import { useTableSectionContext } from './table_section_context'; +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'; +export const SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID = 'signal-rule-id-group-renderer'; + +/** + * Renders an icon and name of an integration. + * This component needs to be used within the TableSectionContext which provides the installed packages as well as all the rules. + */ +export const IntegrationNameGroupContent = memo<{ + title: string | string[]; +}>(({ title }) => { + const { packages, ruleResponse } = useTableSectionContext(); + const { integration } = useGetIntegrationFromRuleId({ + packages, + ruleId: title, + rules: ruleResponse.rules, + }); + + 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/kibana_alert_related_integrations_cell_renderer.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_related_integrations_cell_renderer.test.tsx new file mode 100644 index 0000000000000..3e82bae53446b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_related_integrations_cell_renderer.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { + KibanaAlertRelatedIntegrationsCellRenderer, + TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID, +} from './kibana_alert_related_integrations_cell_renderer'; +import { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; +import { + INTEGRATION_ICON_TEST_ID, + INTEGRATION_LOADING_SKELETON_TEST_ID, +} from '../common/integration_icon'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks'; + +jest.mock('@kbn/fleet-plugin/public/hooks'); + +const LOADING_SKELETON_TEST_ID = `${TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`; +const ICON_TEST_ID = `${TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`; + +describe('KibanaAlertRelatedIntegrationsCellRenderer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + const packages: PackageListItem[] = []; + + const { queryByTestId } = render( + + ); + + expect(queryByTestId(LOADING_SKELETON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should handle not finding matching integration', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_RULE_PARAMETERS]: [{ related_integrations: { package: ['splunk'] } }], + }; + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'other', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'other', + path: '', + status: installationStatuses.NotInstalled, + title: 'Other', + version: '0.1.0', + }, + ]; + + const { queryByTestId } = render( + + ); + + expect(queryByTestId(LOADING_SKELETON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should show integration icon', () => { + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_RULE_PARAMETERS]: [{ related_integrations: { package: ['splunk'] } }], + }; + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, + ]; + + const { getByTestId, queryByTestId } = render( + + ); + + expect(queryByTestId(LOADING_SKELETON_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_related_integrations_cell_renderer.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_related_integrations_cell_renderer.tsx new file mode 100644 index 0000000000000..928770da54a32 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_related_integrations_cell_renderer.tsx @@ -0,0 +1,73 @@ +/* + * 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 { JsonValue } from '@kbn/utility-types'; +import type { Alert } from '@kbn/alerting-types'; +import { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { IntegrationIcon } from '../common/integration_icon'; +import { getAlertFieldValueAsStringOrNull, isJsonObjectValue } from '../../../utils/type_utils'; + +export const TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID = + 'alert-summary-table-related-integrations-cell-renderer'; + +const RELATED_INTEGRATIONS_FIELD = 'related_integrations'; +const PACKAGE_FIELD = 'package'; + +export interface KibanaAlertRelatedIntegrationsCellRendererProps { + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + alert: Alert; + /** + * List of installed AI for SOC integrations. + * This comes from the additionalContext property on the table. + */ + packages: PackageListItem[]; +} + +/** + * Renders an integration/package icon. Retrieves the package name from the kibana.alert.rule.parameters field in the alert, + * fetches all integrations/packages and use the icon from the one that matches by name. + * Used in AI for SOC alert summary table. + */ +export const KibanaAlertRelatedIntegrationsCellRenderer = memo( + ({ alert, packages }: KibanaAlertRelatedIntegrationsCellRendererProps) => { + const packageName: string | null = useMemo(() => { + const values: JsonValue[] | undefined = alert[ALERT_RULE_PARAMETERS]; + + if (Array.isArray(values) && values.length === 1) { + const value: JsonValue = values[0]; + if (!isJsonObjectValue(value)) return null; + + const relatedIntegration = value[RELATED_INTEGRATIONS_FIELD]; + if (!isJsonObjectValue(relatedIntegration)) return null; + + return getAlertFieldValueAsStringOrNull(relatedIntegration as Alert, PACKAGE_FIELD); + } + + return null; + }, [alert]); + + const integration = useMemo( + () => packages.find((p) => p.name === packageName), + [packages, packageName] + ); + + return ( + + ); + } +); + +KibanaAlertRelatedIntegrationsCellRenderer.displayName = + 'KibanaAlertRelatedIntegrationsCellRenderer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_severity_cell_renderer.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_severity_cell_renderer.test.tsx new file mode 100644 index 0000000000000..f291fe8198128 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_severity_cell_renderer.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { TestProviders } from '../../../../common/mock'; +import { + BADGE_TEST_ID, + KibanaAlertSeverityCellRenderer, +} from './kibana_alert_severity_cell_renderer'; +import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; + +describe('KibanaAlertSeverityCellRenderer', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + + const { container } = render( + + + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should show low', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_SEVERITY]: ['low'], + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(BADGE_TEST_ID)).toHaveTextContent('Low'); + expect(getByTestId(BADGE_TEST_ID)).toHaveStyle('--euiBadgeBackgroundColor: #54B399'); + }); + + it('should show medium', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_SEVERITY]: ['medium'], + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(BADGE_TEST_ID)).toHaveTextContent('Medium'); + expect(getByTestId(BADGE_TEST_ID)).toHaveStyle('--euiBadgeBackgroundColor: #F1D86F'); + }); + + it('should show high', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_SEVERITY]: ['high'], + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(BADGE_TEST_ID)).toHaveTextContent('High'); + expect(getByTestId(BADGE_TEST_ID)).toHaveStyle('--euiBadgeBackgroundColor: #FF7E62'); + }); + + it('should show critical', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_SEVERITY]: ['critical'], + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(BADGE_TEST_ID)).toHaveTextContent('Critical'); + expect(getByTestId(BADGE_TEST_ID)).toHaveStyle('--euiBadgeBackgroundColor: #bd271e'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_severity_cell_renderer.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_severity_cell_renderer.tsx new file mode 100644 index 0000000000000..2e506c2d4a6d9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/kibana_alert_severity_cell_renderer.tsx @@ -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 React, { memo, useMemo } from 'react'; +import { EuiBadge, useEuiTheme } from '@elastic/eui'; +import type { Alert } from '@kbn/alerting-types'; +import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import type { JsonValue } from '@kbn/utility-types'; +import { getSeverityColor } from '../../alerts_kpis/severity_level_panel/helpers'; + +export const BADGE_TEST_ID = 'alert-summary-table-severity-cell-renderer'; + +/** + * Return the same string with the first letter capitalized + */ +const capitalizeFirstLetter = (value: string): string => + String(value).charAt(0).toUpperCase() + String(value).slice(1); + +export interface KibanaAlertSeverityCellRendererProps { + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + alert: Alert; +} + +/** + * Renders a EuiBadge for the kibana.alert.severity field. + * Used in AI for SOC alert summary table. + */ +export const KibanaAlertSeverityCellRenderer = memo( + ({ alert }: KibanaAlertSeverityCellRendererProps) => { + const { euiTheme } = useEuiTheme(); + + const displayValue: string | null = useMemo(() => { + const values: JsonValue[] | undefined = alert[ALERT_SEVERITY]; + + if (Array.isArray(values) && values.length === 1) { + const value: JsonValue = values[0]; + return value && typeof value === 'string' ? capitalizeFirstLetter(value) : null; + } + + return null; + }, [alert]); + + const color: string = useMemo( + () => getSeverityColor(displayValue || '', euiTheme), + [displayValue, euiTheme] + ); + + return ( + <> + {displayValue && ( + + {displayValue} + + )} + + ); + } +); + +KibanaAlertSeverityCellRenderer.displayName = 'KibanaAlertSeverityCellRenderer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx new file mode 100644 index 0000000000000..96dfafcbdbe2f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { + MORE_ACTIONS_BUTTON_TEST_ID, + MoreActionsRowControlColumn, +} from './more_actions_row_control_column'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { useKibana } from '../../../../common/lib/kibana'; +import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; +import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); + +describe('MoreActionsRowControlColumn', () => { + it('should render component with all options', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + ...mockCasesContract(), + helpers: { + canUseCases: jest.fn().mockReturnValue({ + read: true, + createComment: true, + }), + getRuleIdFromEvent: jest.fn(), + }, + }, + }, + }); + + const ecsAlert: Ecs = { + _id: '_id', + _index: '_index', + event: { kind: ['signal'] }, + kibana: { alert: { workflow_tags: [] } }, + }; + + const { getByTestId } = render(); + + const button = getByTestId(MORE_ACTIONS_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + + button.click(); + + expect(getByTestId('add-to-existing-case-action')).toBeInTheDocument(); + expect(getByTestId('add-to-new-case-action')).toBeInTheDocument(); + expect(getByTestId('alert-tags-context-menu-item')).toBeInTheDocument(); + }); + + it('should not show cases actions if user is not authorized', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + ...mockCasesContract(), + helpers: { + canUseCases: jest.fn().mockReturnValue({ + read: false, + createComment: false, + }), + getRuleIdFromEvent: jest.fn(), + }, + }, + }, + }); + + const ecsAlert: Ecs = { + _id: '_id', + _index: '_index', + event: { kind: ['signal'] }, + kibana: { alert: { workflow_tags: [] } }, + }; + + const { getByTestId, queryByTestId } = render( + + ); + + const button = getByTestId(MORE_ACTIONS_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + + button.click(); + + expect(queryByTestId('add-to-existing-case-action')).not.toBeInTheDocument(); + expect(queryByTestId('add-to-new-case-action')).not.toBeInTheDocument(); + }); + + it('should not show tags actions if user is not authorized', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + ...mockCasesContract(), + helpers: { + canUseCases: jest.fn().mockReturnValue({ + read: true, + createComment: true, + }), + getRuleIdFromEvent: jest.fn(), + }, + }, + }, + }); + + const ecsAlert: Ecs = { + _id: '_id', + _index: '_index', + event: { kind: ['signal'] }, + kibana: { alert: { workflow_tags: [] } }, + }; + + const { getByTestId, queryByTestId } = render( + + ); + + const button = getByTestId(MORE_ACTIONS_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + + button.click(); + + expect(queryByTestId('alert-tags-context-menu-item')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.tsx new file mode 100644 index 0000000000000..3075efbc8b338 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.tsx @@ -0,0 +1,102 @@ +/* + * 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, useState } from 'react'; +import { EuiButtonIcon, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { i18n } from '@kbn/i18n'; +import { useAlertTagsActions } from '../../alerts_table/timeline_actions/use_alert_tags_actions'; +import { useAddToCaseActions } from '../../alerts_table/timeline_actions/use_add_to_case_actions'; + +export const MORE_ACTIONS_BUTTON_TEST_ID = 'alert-summary-table-row-action-more-actions'; + +export const MORE_ACTIONS_BUTTON_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.alertSummary.table.moreActionsAriaLabel', + { + defaultMessage: 'More actions', + } +); +export const ADD_TO_CASE_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.alertSummary.table.attachToCaseAriaLabel', + { + defaultMessage: 'Attach alert to case', + } +); + +export interface MoreActionsRowControlColumnProps { + /** + * Alert data + * The Ecs type is @deprecated but needed for the case actions within the more action dropdown + */ + ecsAlert: Ecs; +} + +/** + * Renders a horizontal 3-dot button which displays a context menu when clicked. + * This is used in the AI for SOC alert summary table. + * The following options are available: + * - add to existing case + * - add to new case + * - apply alert tags + */ +export const MoreActionsRowControlColumn = memo( + ({ ecsAlert }: MoreActionsRowControlColumnProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const button = useMemo( + () => ( + + ), + [togglePopover] + ); + + const { addToCaseActionItems } = useAddToCaseActions({ + ecsData: ecsAlert, + onMenuItemClick: closePopover, + isActiveTimelines: false, + ariaLabel: ADD_TO_CASE_ARIA_LABEL, + isInDetections: true, + }); + + const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({ + closePopover, + ecsRowData: ecsAlert, + }); + + const panels = useMemo( + () => [ + { + id: 0, + items: [...addToCaseActionItems, ...alertTagsItems], + }, + ...alertTagsPanels, + ], + [addToCaseActionItems, alertTagsItems, alertTagsPanels] + ); + + return ( + + + + ); + } +); + +MoreActionsRowControlColumn.displayName = 'MoreActionsRowControlColumn'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/open_flyout_row_control_column.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/open_flyout_row_control_column.test.tsx new file mode 100644 index 0000000000000..59c4e53851a72 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/open_flyout_row_control_column.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 { + OpenFlyoutRowControlColumn, + ROW_ACTION_FLYOUT_ICON_TEST_ID, +} from './open_flyout_row_control_column'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { IOCPanelKey } from '../../../../flyout/ai_for_soc/constants/panel_keys'; + +jest.mock('@kbn/expandable-flyout'); + +describe('OpenFlyoutRowControlColumn', () => { + it('should render button icon', () => { + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ + openFlyout: jest.fn(), + }); + + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + + const { getByTestId } = render(); + + expect(getByTestId(ROW_ACTION_FLYOUT_ICON_TEST_ID)).toBeInTheDocument(); + }); + + it('should open flyout after click', () => { + const openFlyout = jest.fn(); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ + openFlyout, + }); + + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + + const { getByTestId } = render(); + + getByTestId(ROW_ACTION_FLYOUT_ICON_TEST_ID).click(); + + expect(openFlyout).toHaveBeenCalledWith({ + right: { + id: IOCPanelKey, + params: { + id: alert._id, + indexName: alert._index, + }, + }, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/open_flyout_row_control_column.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/open_flyout_row_control_column.tsx new file mode 100644 index 0000000000000..dcfc0fc7ebfa5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/open_flyout_row_control_column.tsx @@ -0,0 +1,57 @@ +/* + * 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 } from 'react'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { EuiButtonIcon } from '@elastic/eui'; +import type { Alert } from '@kbn/alerting-types'; +import { i18n } from '@kbn/i18n'; +import { IOCPanelKey } from '../../../../flyout/ai_for_soc/constants/panel_keys'; + +export const ROW_ACTION_FLYOUT_ICON_TEST_ID = 'alert-summary-table-row-action-flyout-icon'; + +export interface ActionsCellProps { + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + alert: Alert; +} + +/** + * Renders a icon to open the AI for SOC alert summary flyout. + */ +export const OpenFlyoutRowControlColumn = memo(({ alert }: ActionsCellProps) => { + const { openFlyout } = useExpandableFlyoutApi(); + const onOpenFlyout = useCallback( + () => + openFlyout({ + right: { + id: IOCPanelKey, + params: { + id: alert._id, + indexName: alert._index, + }, + }, + }), + [alert, openFlyout] + ); + + return ( + + ); +}); + +OpenFlyoutRowControlColumn.displayName = 'OpenFlyoutRowControlColumn'; 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..81ac3aeca407c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.test.tsx @@ -0,0 +1,221 @@ +/* + * 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'; +import { ALERT_RULE_PARAMETERS, ALERT_SEVERITY, TIMESTAMP } from '@kbn/rule-data-utils'; +import { BADGE_TEST_ID } from './kibana_alert_severity_cell_renderer'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID } from './kibana_alert_related_integrations_cell_renderer'; +import { INTEGRATION_ICON_TEST_ID } from '../common/integration_icon'; + +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, +]; + +describe('CellValue', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const columnId = 'columnId'; + const schema = 'unknown'; + + 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 schema = 'string'; + + const { getByText } = render( + + + + ); + + expect(getByText('value1')).toBeInTheDocument(); + }); + + it('should handle a number value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 123, + }; + const columnId = 'field1'; + const schema = 'unknown'; + + const { getByText } = render( + + + + ); + + expect(getByText('123')).toBeInTheDocument(); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const columnId = 'field1'; + const schema = 'unknown'; + + 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 schema = 'unknown'; + + 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 schema = 'unknown'; + + 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 schema = 'unknown'; + + const { getByText } = render( + + + + ); + + expect(getByText('[object Object]')).toBeInTheDocument(); + }); + + it('should use related integration renderer', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_RULE_PARAMETERS]: [{ related_integrations: { package: ['splunk'] } }], + }; + const columnId = ALERT_RULE_PARAMETERS; + const schema = 'unknown'; + + const { getByTestId } = render( + + + + ); + + expect( + getByTestId(`${TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`) + ).toBeInTheDocument(); + }); + + it('should use severity renderer', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [ALERT_SEVERITY]: ['low'], + }; + const columnId = ALERT_SEVERITY; + const schema = 'unknown'; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(BADGE_TEST_ID)).toBeInTheDocument(); + }); + + it('should use datetime renderer', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + [TIMESTAMP]: [1735754400000], + }; + const columnId = TIMESTAMP; + const schema = 'datetime'; + + const { getByText } = render( + + + + ); + + expect(getByText('Jan 1, 2025 @ 18:00:00.000')).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..5690727b4ffec --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/render_cell.tsx @@ -0,0 +1,65 @@ +/* + * 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, { type ComponentProps, memo } from 'react'; +import { ALERT_RULE_PARAMETERS, ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import type { GetTableProp } from './types'; +import { DatetimeSchemaCellRenderer } from './datetime_schema_cell_renderer'; +import { BasicCellRenderer } from './basic_cell_renderer'; +import { KibanaAlertSeverityCellRenderer } from './kibana_alert_severity_cell_renderer'; +import { KibanaAlertRelatedIntegrationsCellRenderer } from './kibana_alert_related_integrations_cell_renderer'; + +const DATETIME_SCHEMA = 'datetime'; + +export type CellValueProps = Pick< + ComponentProps>, + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + | 'alert' + /** + * Column id passed from the renderCellValue callback via EuiDataGridProps['renderCellValue'] interface + */ + | 'columnId' + /** + * List of installed AI for SOC integrations. + * This comes from the additionalContext property on the table. + */ + | 'packages' + /** + * Type of field used to drive how we render the value in the BasicCellRenderer. + * This comes from EuiDataGrid. + */ + | 'schema' +>; + +/** + * Component used in the AI for SOC alert summary table. + * It renders some of the value with custom renderers for some specific columns: + * - kibana.alert.rule.parameters + * - kibana.alert.severity + * It also renders some schema types specifically (this property come from EuiDataGrid): + * - datetime + * Finally it renders the rest as basic strings. + */ +export const CellValue = memo(({ alert, columnId, packages, schema }: CellValueProps) => { + let component; + + if (columnId === ALERT_RULE_PARAMETERS) { + component = ; + } else if (columnId === ALERT_SEVERITY) { + component = ; + } else if (schema === DATETIME_SCHEMA) { + component = ; + } else { + component = ; + } + + return <>{component}; +}); + +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..8b232857b4ddd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.test.tsx @@ -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 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'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; + +const dataView: DataView = createStubDataView({ spec: {} }); +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, +]; +const ruleResponse = { + rules: [], + isLoading: false, +}; + +describe('
', () => { + it('should render all components', () => { + const { getByTestId } = render( + +
+ + ); + + expect(getByTestId('internalAlertsPageLoading')).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..15bed1a7bc5dc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx @@ -0,0 +1,285 @@ +/* + * 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, useRef } 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 { + AlertsTableImperativeApi, + AlertsTableProps, +} from '@kbn/response-ops-alerts-table/types'; +import { + ALERT_RULE_NAME, + ALERT_RULE_PARAMETERS, + ALERT_SEVERITY, + AlertConsumers, + TIMESTAMP, +} 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 type { PackageListItem } from '@kbn/fleet-plugin/common'; +import styled from '@emotion/styled'; +import { useAdditionalBulkActions } from '../../../hooks/alert_summary/use_additional_bulk_actions'; +import { APP_ID, CASES_FEATURE_ID } from '../../../../../common'; +import { ActionsCell } from './actions_cell'; +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'; +import type { RuleResponse } from '../../../../../common/api/detection_engine'; + +export const TIMESTAMP_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.timeStamp', + { defaultMessage: 'Timestamp' } +); +export const RELATION_INTEGRATION_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.relatedIntegrationName', + { defaultMessage: 'Integration' } +); +export const SEVERITY_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.severity', + { defaultMessage: 'Severity' } +); +export const RULE_NAME_COLUMN = i18n.translate( + 'xpack.securitySolution.alertSummary.table.column.ruleName', + { defaultMessage: 'Rule' } +); + +export const columns: EuiDataGridProps['columns'] = [ + { + id: TIMESTAMP, + displayAsText: TIMESTAMP_COLUMN, + }, + { + id: ALERT_RULE_PARAMETERS, + displayAsText: RELATION_INTEGRATION_COLUMN, + }, + { + id: ALERT_SEVERITY, + displayAsText: SEVERITY_COLUMN, + }, + { + id: ALERT_RULE_NAME, + displayAsText: RULE_NAME_COLUMN, + }, +]; + +export const ACTION_COLUMN_WIDTH = 72; // px +export const ALERT_TABLE_CONSUMERS: AlertsTableProps['consumers'] = [AlertConsumers.SIEM]; +export const RULE_TYPE_IDS = [ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID]; +export const ROW_HEIGHTS_OPTIONS = { defaultHeight: 40 }; +export const TOOLBAR_VISIBILITY: EuiDataGridToolBarVisibilityOptions = { + showDisplaySelector: false, + showKeyboardShortcuts: false, + showFullScreenSelector: false, +}; +export const GRID_STYLE: EuiDataGridStyle = { border: 'horizontal' }; +export const CASES_CONFIGURATION = { + featureId: CASES_FEATURE_ID, + owner: [APP_ID], + syncAlerts: true, +}; + +// This will guarantee that ALL cells will have their values vertically centered. +// While these styles were originally applied in the RenderCell component, they were not applied to the bulk action checkboxes. +// These are necessary because the ResponseOps alerts table is not centering values vertically, which is visible when using a custom row height. +export const EuiDataGridStyleWrapper = styled.div` + div .euiDataGridRowCell__content { + align-items: center; + display: flex; + height: 100%; + } +`; + +export interface AdditionalTableContext { + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +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[]; + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +/** + * 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, packages, ruleResponse }: TableProps) => { + const { + services: { + application, + cases, + data, + fieldFormats, + http, + licensing, + notifications, + uiSettings, + settings, + }, + } = useKibana(); + const services = useMemo( + () => ({ + cases, + data, + http, + notifications, + fieldFormats, + application, + licensing, + settings, + }), + [application, cases, 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: [], + indexPattern: dataView, + 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, dataView, filters, globalQuery, uiSettings]); + + const renderAdditionalToolbarControls = useCallback( + () => , + [dataView] + ); + + const additionalContext: AdditionalTableContext = useMemo( + () => ({ + packages, + ruleResponse, + }), + [packages, ruleResponse] + ); + + const refetchRef = useRef(null); + const refetch = useCallback(() => { + refetchRef.current?.refresh(); + }, []); + + const bulkActions = useAdditionalBulkActions({ refetch }); + + 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..4ce0c02c38c8f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.test.tsx @@ -0,0 +1,47 @@ +/* + * 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'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; + +const dataView: DataView = createStubDataView({ spec: {} }); +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, +]; +const ruleResponse = { + rules: [], + isLoading: false, +}; + +describe('', () => { + it('should render all components', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId(GROUPED_TABLE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId('internalAlertsPageLoading')).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..d28306239c2c7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section.tsx @@ -0,0 +1,110 @@ +/* + * 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 type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { TableSectionContextProvider } from './table_section_context'; +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 type { RuleResponse } from '../../../../../common/api/detection_engine'; + +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; + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +/** + * 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, packages, ruleResponse }: 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 accordionExtraActionGroupStats = useMemo( + () => ({ + aggregations: groupStatsAggregations, + renderer: groupStatsRenderer, + }), + [] + ); + + const renderChildComponent = useCallback( + (groupingFilters: Filter[]) => ( +
+ ), + [dataView, packages, ruleResponse] + ); + + return ( + +
+ +
+
+ ); +}); + +TableSection.displayName = 'TableSection'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section_context.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section_context.tsx new file mode 100644 index 0000000000000..6ada4adff9597 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table_section_context.tsx @@ -0,0 +1,71 @@ +/* + * 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, { createContext, memo, useContext, useMemo } from 'react'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import type { RuleResponse } from '../../../../../common/api/detection_engine'; + +export interface TableSectionContext { + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +/** + * A context provider for the AI for SOC alert summary table grouping component. + * This allows group stats and renderers to not have to fetch rules and packages. + */ +export const TableSectionContext = createContext(undefined); + +export type TableSectionContextProviderProps = { + /** + * React components to render + */ + children: React.ReactNode; +} & TableSectionContext; + +export const TableSectionContextProvider = memo( + ({ children, packages, ruleResponse }: TableSectionContextProviderProps) => { + const contextValue = useMemo( + () => ({ + packages, + ruleResponse, + }), + [packages, ruleResponse] + ); + + return ( + {children} + ); + } +); + +TableSectionContextProvider.displayName = 'TableSectionContextProvider'; + +export const useTableSectionContext = (): TableSectionContext => { + const contextValue = useContext(TableSectionContext); + + if (!contextValue) { + throw new Error('TableSectionContext can only be used within TableSectionContext provider'); + } + + return contextValue; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/types.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/types.ts new file mode 100644 index 0000000000000..ab25e1f137d00 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/types.ts @@ -0,0 +1,12 @@ +/* + * 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 { AlertsTablePropsWithRef } from '@kbn/response-ops-alerts-table/types'; +import type { AdditionalTableContext } from './table'; + +export type TableProps = AlertsTablePropsWithRef; +export type GetTableProp = NonNullable; 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 new file mode 100644 index 0000000000000..8a064eb449096 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.test.tsx @@ -0,0 +1,141 @@ +/* + * 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, screen, waitFor } from '@testing-library/react'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { + CONTENT_TEST_ID, + DATA_VIEW_ERROR_TEST_ID, + DATA_VIEW_LOADING_PROMPT_TEST_ID, + SKELETON_TEST_ID, + Wrapper, +} from './wrapper'; +import { useKibana } from '../../../common/lib/kibana'; +import { TestProviders } from '../../../common/mock'; +import { useIntegrationsLastActivity } from '../../hooks/alert_summary/use_integrations_last_activity'; +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'; +import { useNavigateToIntegrationsPage } from '../../hooks/alert_summary/use_navigate_to_integrations_page'; + +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('../../hooks/alert_summary/use_navigate_to_integrations_page'); +jest.mock('../../hooks/alert_summary/use_integrations_last_activity'); + +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '', + }, +]; +const ruleResponse = { + rules: [], + isLoading: false, +}; + +describe('', () => { + it('should render a loading skeleton while creating the dataView', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn(), + clearInstanceCache: jest.fn(), + }, + }, + http: { basePath: { prepend: jest.fn() } }, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(SKELETON_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('should render an error if the dataView fail to be created correctly', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn().mockReturnValue(undefined), + clearInstanceCache: jest.fn(), + }, + }, + }, + }); + + jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: jest.fn((f) => f()), + })); + + render(); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(DATA_VIEW_ERROR_TEST_ID)).toHaveTextContent( + 'Unable to create data view' + ); + }); + + it('should render the content if the dataView is created correctly', async () => { + (useNavigateToIntegrationsPage as jest.Mock).mockReturnValue(jest.fn()); + (useIntegrationsLastActivity as jest.Mock).mockReturnValue({ + isLoading: true, + lastActivities: {}, + }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest + .fn() + .mockReturnValue({ getIndexPattern: jest.fn(), id: 'id', toSpec: jest.fn() }), + clearInstanceCache: jest.fn(), + }, + query: { filterManager: { getFilters: jest.fn() } }, + }, + }, + }); + + jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: jest.fn((f) => f()), + })); + + render( + + + + ); + + expect(await screen.findByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(CONTENT_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument(); + expect(await screen.findByTestId(KPIS_SECTION)).toBeInTheDocument(); + expect(await screen.findByTestId(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 new file mode 100644 index 0000000000000..d3e1b2d968ba2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/wrapper.tsx @@ -0,0 +1,134 @@ +/* + * 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, useEffect, useState } from 'react'; +import { + EuiEmptyPrompt, + EuiHorizontalRule, + EuiSkeletonLoading, + EuiSkeletonRectangle, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; +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', +}); + +export const DATA_VIEW_LOADING_PROMPT_TEST_ID = 'alert-summary-data-view-loading-prompt'; +export const DATA_VIEW_ERROR_TEST_ID = 'alert-summary-data-view-error'; +export const SKELETON_TEST_ID = 'alert-summary-skeleton'; +export const CONTENT_TEST_ID = 'alert-summary-content'; + +const dataViewSpec: DataViewSpec = { title: '.alerts-security.alerts-default' }; + +export interface WrapperProps { + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +/** + * Creates a new adhoc dataView for the alert summary page. The dataView is created just with the alert indices. + * During the creating, we display a loading skeleton, mimicking the future alert summary page content. + * Once the dataView is correctly created, we render the content. + * If the creation fails, we show an error message. + */ +export const Wrapper = memo(({ packages, ruleResponse }: WrapperProps) => { + const { data } = useKibana().services; + const [dataView, setDataView] = useState(undefined); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let dv: DataView; + const createDataView = async () => { + try { + dv = await data.dataViews.create(dataViewSpec); + setDataView(dv); + setLoading(false); + } catch (err) { + setLoading(false); + } + }; + createDataView(); + + // clearing after leaving the page + return () => { + if (dv?.id) { + data.dataViews.clearInstanceCache(dv.id); + } + }; + }, [data.dataViews]); + + return ( + + + + + + + + +
+ } + loadedContent={ + <> + {!dataView || !dataView.id ? ( + {DATAVIEW_ERROR}} + /> + ) : ( +
+ + + + + + + +
+ )} + + } + /> + ); +}); + +Wrapper.displayName = 'Wrapper'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.test.tsx index 09b9afff4dce5..acfae0cea67cb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.test.tsx @@ -18,29 +18,24 @@ jest.mock('react-router-dom', () => { }); describe('Alert by rule chart', () => { - const defaultProps = { - data: [], - isLoading: false, - }; - - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); - test('renders table correctly without data', () => { + test('should render the table correctly without data', () => { const { getByTestId } = render( - + ); expect(getByTestId('alerts-by-rule-table')).toBeInTheDocument(); expect(getByTestId('alerts-by-rule-table')).toHaveTextContent('No items found'); }); - test('renders table correctly with data', () => { + test('should render the table correctly with data', () => { const { queryAllByRole } = render( - + ); @@ -50,4 +45,16 @@ describe('Alert by rule chart', () => { expect(queryAllByRole('row')[i + 1].children).toHaveLength(3); }); }); + + test('should render the table without the third columns (for cell actions)', () => { + const { queryAllByRole } = render( + + + + ); + + parsedAlerts.forEach((_, i) => { + expect(queryAllByRole('row')[i + 1].children).toHaveLength(2); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.tsx index 4cda599776fa9..e13f30cec4456 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/alerts_by_rule.tsx @@ -5,24 +5,12 @@ * 2.0. */ -import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiInMemoryTable, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiInMemoryTable, EuiSpacer } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; -import { TableId } from '@kbn/securitysolution-data-table'; +import { useGetAlertsByRuleColumns } from './columns'; import type { AlertsByRuleData } from './types'; -import { FormattedCount } from '../../../../common/components/formatted_number'; -import { ALERTS_HEADERS_RULE_NAME } from '../../alerts_table/translations'; -import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations'; -import { - CellActionsMode, - SecurityCellActionsTrigger, - SecurityCellActions, - SecurityCellActionType, -} from '../../../../common/components/cell_actions'; -import { getSourcererScopeId } from '../../../../helpers'; const Wrapper = styled.div` margin-top: -${({ theme }) => theme.eui.euiSizeM}; @@ -31,57 +19,6 @@ const TableWrapper = styled.div` height: 210px; `; -export interface AlertsByRuleProps { - data: AlertsByRuleData[]; - isLoading: boolean; -} - -const COLUMNS: Array> = [ - { - field: 'rule', - name: ALERTS_HEADERS_RULE_NAME, - 'data-test-subj': 'alert-by-rule-table-rule-name', - truncateText: true, - render: (rule: string) => ( - - {rule} - - ), - }, - { - field: 'value', - name: COUNT_TABLE_TITLE, - dataType: 'number', - sortable: true, - 'data-test-subj': 'alert-by-rule-table-count', - render: (count: number) => ( - - - - ), - width: '22%', - }, - { - field: 'rule', - name: '', - 'data-test-subj': 'alert-by-rule-table-actions', - width: '10%', - render: (rule: string) => ( - - ), - }, -]; - const SORTING: { sort: { field: keyof AlertsByRuleData; direction: SortOrder } } = { sort: { field: 'value', @@ -94,14 +31,31 @@ const PAGINATION: {} = { showPerPageOptions: false, }; -export const AlertsByRule: React.FC = ({ data, isLoading }) => { +export interface AlertsByRuleProps { + /** + * Chart data + */ + data: AlertsByRuleData[]; + /** + * If true, renders the UIInMemoryTable loading state + */ + isLoading: boolean; + /** + * If true, render the last column for cell actions (like filter for, out, add to timeline, copy...) + */ + showCellActions: boolean; +} + +export const AlertsByRule: React.FC = ({ data, isLoading, showCellActions }) => { + const columns = useGetAlertsByRuleColumns(showCellActions); + return ( { + it('should return base columns (2)', () => { + const { result } = renderHook(() => useGetAlertsByRuleColumns(false)); + expect(result.current).toHaveLength(2); + }); + + it('should return all columns (3)', () => { + const { result } = renderHook(() => useGetAlertsByRuleColumns(true)); + expect(result.current).toHaveLength(3); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/columns.tsx new file mode 100644 index 0000000000000..7b683149b56b3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/columns.tsx @@ -0,0 +1,82 @@ +/* + * 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, { useMemo } from 'react'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; +import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { FormattedCount } from '../../../../common/components/formatted_number'; +import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations'; +import { + CellActionsMode, + SecurityCellActions, + SecurityCellActionsTrigger, + SecurityCellActionType, +} from '../../../../common/components/cell_actions'; +import { getSourcererScopeId } from '../../../../helpers'; +import { ALERTS_HEADERS_RULE_NAME } from '../../alerts_table/translations'; +import type { AlertsByRuleData } from './types'; + +const BASE_COLUMNS: Array> = [ + { + field: 'rule', + name: ALERTS_HEADERS_RULE_NAME, + 'data-test-subj': 'alert-by-rule-table-rule-name', + truncateText: true, + render: (rule: string) => ( + + {rule} + + ), + }, + { + field: 'value', + name: COUNT_TABLE_TITLE, + dataType: 'number', + sortable: true, + 'data-test-subj': 'alert-by-rule-table-count', + render: (count: number) => ( + + + + ), + width: '22%', + }, +]; + +const CELL_ACTIONS_COLUMN: EuiBasicTableColumn = { + field: 'rule', + name: '', + 'data-test-subj': 'alert-by-rule-table-actions', + width: '10%', + render: (rule: string) => ( + + ), +}; + +const ALL_COLUMNS = [...BASE_COLUMNS, CELL_ACTIONS_COLUMN]; + +/** + * Returns the list of columns for the severity table for the KPI charts + * @param showCellActions if true, add a third column for cell actions + */ +export const useGetAlertsByRuleColumns = ( + showCellActions: boolean +): Array> => { + return useMemo(() => (showCellActions ? ALL_COLUMNS : BASE_COLUMNS), [showCellActions]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/index.tsx index 98e98698f6083..127f5695abbae 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/index.tsx @@ -19,12 +19,17 @@ import * as i18n from './translations'; const ALERTS_BY_TYPE_CHART_ID = 'alerts-summary-alert_by_type'; +/** + * Renders a table showing alerts grouped by rule names. + * The component is used in the alerts page as well as in the AI for SOC alert summary page. + */ export const AlertsByRulePanel: React.FC = ({ filters, query, signalIndexName, runtimeMappings, skip, + showCellActions = true, }) => { const uniqueQueryId = useMemo(() => `${ALERTS_BY_TYPE_CHART_ID}-${uuid()}`, []); @@ -50,7 +55,7 @@ export const AlertsByRulePanel: React.FC = ({ titleSize="xs" hideSubtitle /> - + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx index 504c80fedd5aa..10ffad7c72ac6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx @@ -15,11 +15,11 @@ import { EuiProgress, EuiSpacer, EuiText, - useEuiTheme, } from '@elastic/eui'; import React, { useMemo, useState } from 'react'; import styled from 'styled-components'; import { TableId } from '@kbn/securitysolution-data-table'; +import { ProgressBarRow } from './alerts_progress_bar_row'; import type { AlertsProgressBarData, GroupBySelection } from './types'; import type { AddFilterProps } from '../common/types'; import { getAggregateData } from './helpers'; @@ -57,47 +57,22 @@ const EmptyAction = styled.div` padding-left: ${({ theme }) => theme.eui.euiSizeL}; `; -/** - * Individual progress bar per row - */ -const ProgressBarRow: React.FC<{ item: AlertsProgressBarData }> = ({ item }) => { - const { euiTheme } = useEuiTheme(); - const color = useMemo( - () => - euiTheme.themeName === 'EUI_THEME_BOREALIS' - ? euiTheme.colors.vis.euiColorVis6 - : euiTheme.colors.vis.euiColorVis9, - [euiTheme] - ); - - return ( - - {item.percentageLabel} - - } - max={1} - color={color} - size="s" - value={item.percentage} - label={ - item.key === 'Other' ? ( - item.label - ) : ( - - {item.key} - - ) - } - /> - ); -}; - export interface AlertsProcessBarProps { + /** + * Alerts data + */ data: AlertsProgressBarData[]; + /** + * If true, component renders an EuiProgressBar + */ isLoading: boolean; + /** + * Callback to allow the charts to add filters to the SiemSearchBar + */ addFilter?: ({ field, value, negate }: AddFilterProps) => void; + /** + * Field the alerts data is grouped by + */ groupBySelection: GroupBySelection; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar_row.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar_row.test.tsx new file mode 100644 index 0000000000000..aa061b1e5e077 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar_row.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { ProgressBarRow } from './alerts_progress_bar_row'; +import type { AlertsProgressBarData } from './types'; + +describe('ProgressBarRow', () => { + it('should render label value when key is Other', () => { + const item: AlertsProgressBarData = { + key: 'Other', + value: 1, + percentage: 50, + percentageLabel: 'percentageLabel', + label: 'label', + }; + + const { getByText } = render(); + + expect(getByText('percentageLabel')).toBeInTheDocument(); + expect(getByText('label')).toBeInTheDocument(); + }); + + it('should render key value when key is not Other', () => { + const item: AlertsProgressBarData = { + key: 'key', + value: 1, + percentage: 50, + percentageLabel: 'percentageLabel', + label: 'label', + }; + + const { getByText } = render(); + + expect(getByText('percentageLabel')).toBeInTheDocument(); + expect(getByText('key')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar_row.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar_row.tsx new file mode 100644 index 0000000000000..ecf7d145811db --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar_row.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EuiProgress, EuiText, useEuiTheme } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import type { AlertsProgressBarData } from './types'; + +/** + * Renders a EuiProgress, used in the KPI chart for the alerts page as well as the AI for SOC alert summary page. + */ +export const ProgressBarRow: React.FC<{ item: AlertsProgressBarData }> = ({ item }) => { + const { euiTheme } = useEuiTheme(); + const color = useMemo( + () => + euiTheme.themeName === 'EUI_THEME_BOREALIS' + ? euiTheme.colors.vis.euiColorVis6 + : euiTheme.colors.vis.euiColorVis9, + [euiTheme] + ); + + return ( + + {item.percentageLabel} + + } + max={1} + color={color} + size="s" + value={item.percentage} + label={ + item.key === 'Other' ? ( + item.label + ) : ( + + {item.key} + + ) + } + /> + ); +}; + +ProgressBarRow.displayName = 'ProgressBarRow'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx index e175dda7ff278..01b5b03b79017 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx @@ -7,8 +7,7 @@ import { EuiPanel } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { v4 as uuid } from 'uuid'; -import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; -import type { Filter, Query } from '@kbn/es-query'; +import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types'; import { HeaderSection } from '../../../../common/components/header_section'; import { InspectButtonContainer } from '../../../../common/components/inspect'; import { StackByComboBox } from '../common/components'; @@ -18,23 +17,28 @@ import { alertsGroupingAggregations } from '../alerts_summary_charts_panel/aggre import { getIsAlertsProgressBarData } from './helpers'; import * as i18n from './translations'; import type { GroupBySelection } from './types'; -import type { AddFilterProps } from '../common/types'; const TOP_ALERTS_CHART_ID = 'alerts-summary-top-alerts'; const DEFAULT_COMBOBOX_WIDTH = 150; const DEFAULT_OPTIONS = ['host.name', 'user.name', 'source.ip', 'destination.ip']; -interface Props { - filters?: Filter[]; - query?: Query; - signalIndexName: string | null; - runtimeMappings?: MappingRuntimeFields; - skip?: boolean; +interface AlertsProgressBarPanelProps extends ChartsPanelProps { + /** + * Field to group the alerts by + */ groupBySelection: GroupBySelection; + /** + * Callback to set which field to group the alerts by + */ setGroupBySelection: (groupBySelection: GroupBySelection) => void; - addFilter?: ({ field, value, negate }: AddFilterProps) => void; } -export const AlertsProgressBarPanel: React.FC = ({ + +/** + * Renders a list showing the percentages of alerts grouped by a property. + * The component is used in the alerts page, where users can select what fields they want the alerts to be grouped by, + * and in the AI for SOC alert summary page where the alerts are automatically grouped by host. + */ +export const AlertsProgressBarPanel: React.FC = ({ filters, query, signalIndexName, @@ -70,12 +74,17 @@ export const AlertsProgressBarPanel: React.FC = ({ }); const data = useMemo(() => (getIsAlertsProgressBarData(items) ? items : []), [items]); + const inspectTitle = useMemo( + () => `${i18n.ALERT_BY_TITLE} ${groupBySelection}`, + [groupBySelection] + ); + return ( void; + /** + * Callback to allow the charts to add filters to the SiemSearchBar + */ + addFilter?: ({ field, value, negate }: AddFilterProps) => void; + /** + * If true, make the cell action interactions visible (filter for, filter out, add to timeline, copy...) + */ + showCellActions?: boolean; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.test.ts new file mode 100644 index 0000000000000..516e68bf69502 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.test.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 { useGetSeverityTableColumns } from './columns'; +import { renderHook } from '@testing-library/react'; + +describe('useGetSeverityTableColumns', () => { + it('should return base columns (2)', () => { + const { result } = renderHook(() => useGetSeverityTableColumns(false)); + expect(result.current).toHaveLength(2); + }); + + it('should return all columns (3)', () => { + const { result } = renderHook(() => useGetSeverityTableColumns(true)); + expect(result.current).toHaveLength(3); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.tsx index f12378a85cda2..4ce4bf8baa9e3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.tsx @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { useMemo } from 'react'; +import type { EuiBasicTableColumn } from '@elastic/eui'; import { EuiHealth, EuiText } from '@elastic/eui'; import { capitalize } from 'lodash'; import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; -import type { EuiBasicTableColumn } from '@elastic/eui'; import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import { TableId } from '@kbn/securitysolution-data-table'; import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; @@ -17,17 +18,23 @@ import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations'; import * as i18n from './translations'; import { CellActionsMode, - SecurityCellActionsTrigger, SecurityCellActions, + SecurityCellActionsTrigger, SecurityCellActionType, } from '../../../../common/components/cell_actions'; import { getSourcererScopeId } from '../../../../helpers'; import { useRiskSeverityColors } from '../../../../common/utils/risk_color_palette'; -export const useGetSeverityTableColumns = (): Array> => { +/** + * Returns the list of columns for the severity table for the KPI charts + * @param showCellActions if true, add a third column for cell actions + */ +export const useGetSeverityTableColumns = ( + showCellActions: boolean +): Array> => { const severityColors = useRiskSeverityColors(); - return useMemo( - () => [ + return useMemo(() => { + const baseColumns: Array> = [ { field: 'key', name: i18n.SEVERITY_LEVEL_COLUMN_TITLE, @@ -50,7 +57,9 @@ export const useGetSeverityTableColumns = (): Array ), }, - { + ]; + if (showCellActions) { + baseColumns.push({ field: 'key', name: '', 'data-test-subj': 'severityTable-actions', @@ -68,8 +77,8 @@ export const useGetSeverityTableColumns = (): Array ), - }, - ], - [severityColors] - ); + }); + } + return baseColumns; + }, [severityColors, showCellActions]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx index aa2583cd9d5c5..518bcb8fde5b5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx @@ -19,6 +19,10 @@ import * as i18n from './translations'; const SEVERITY_DONUT_CHART_ID = 'alerts-summary-severity-donut'; +/** + * Renders a table and a donut chart showing alerts grouped by severity levels. + * The component is used in the alerts page as well as in the AI for SOC alert summary page. + */ export const SeverityLevelPanel: React.FC = ({ filters, query, @@ -26,6 +30,7 @@ export const SeverityLevelPanel: React.FC = ({ runtimeMappings, addFilter, skip, + showCellActions = true, }) => { const uniqueQueryId = useMemo(() => `${SEVERITY_DONUT_CHART_ID}-${uuid()}`, []); @@ -50,7 +55,12 @@ export const SeverityLevelPanel: React.FC = ({ titleSize="xs" hideSubtitle /> - + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.test.tsx index f7fb2a4bb91fb..184722014ffd0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.test.tsx @@ -7,6 +7,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../../common/mock'; +import type { SeverityLevelProps } from './severity_level_chart'; import { SeverityLevelChart } from './severity_level_chart'; import { parsedAlerts } from './mock_data'; @@ -18,16 +19,17 @@ jest.mock('react-router-dom', () => { }); describe('Severity level chart', () => { - const defaultProps = { + const defaultProps: SeverityLevelProps = { data: [], isLoading: false, + showCellActions: true, }; - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); - test('renders severity table correctly', () => { + it('should render the severity table correctly', () => { const { getByTestId } = render( @@ -37,7 +39,7 @@ describe('Severity level chart', () => { expect(getByTestId('severity-level-table')).toHaveTextContent('No items found'); }); - test('renders severity donut correctly', () => { + it('should render the severity donut correctly', () => { const { getByTestId } = render( @@ -46,10 +48,10 @@ describe('Severity level chart', () => { expect(getByTestId('severity-level-donut')).toBeInTheDocument(); }); - test('renders table correctly with data', () => { + it('should render the table correctly with data', () => { const { queryAllByRole, getByTestId } = render( - + ); expect(getByTestId('severity-level-table')).toBeInTheDocument(); @@ -59,4 +61,16 @@ describe('Severity level chart', () => { expect(queryAllByRole('row')[i + 1].children).toHaveLength(3); }); }); + + it('should render the table without the third columns (cell actions)', () => { + const { queryAllByRole, getByTestId } = render( + + + + ); + expect(getByTestId('severity-level-table')).toBeInTheDocument(); + parsedAlerts.forEach((_, i) => { + expect(queryAllByRole('row')[i + 1].children).toHaveLength(2); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx index 104bb7ccb092f..b107ce243f4e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { useCallback, useMemo } from 'react'; import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; import styled from 'styled-components'; @@ -27,19 +28,34 @@ const DONUT_HEIGHT = 150; const StyledEuiLoadingSpinner = styled(EuiLoadingSpinner)` margin: auto; `; + export interface SeverityLevelProps { + /** + * Chart data + */ data: SeverityData[]; + /** + * If true, shows a EuiSpinner + */ isLoading: boolean; + /** + * Callback to allow the charts to add filters to the SiemSearchBar + */ addFilter?: ({ field, value }: { field: string; value: string | number }) => void; + /** + * If true, render the last column for cell actions (like filter for, out, add to timeline, copy...) + */ + showCellActions: boolean; } export const SeverityLevelChart: React.FC = ({ data, isLoading, addFilter, + showCellActions, }) => { const { euiTheme } = useEuiTheme(); - const columns = useGetSeverityTableColumns(); + const columns = useGetSeverityTableColumns(showCellActions); const count = useMemo(() => { return data 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_additional_bulk_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_additional_bulk_actions.test.tsx new file mode 100644 index 0000000000000..a434036588ad9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_additional_bulk_actions.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react'; +import { useAdditionalBulkActions } from './use_additional_bulk_actions'; +import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'; + +jest.mock('../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'); + +describe('useAdditionalBulkActions', () => { + it('should return showAssistant true and a value for promptContextId', () => { + (useBulkAlertTagsItems as jest.Mock).mockReturnValue({ + alertTagsItems: ['item'], + alertTagsPanels: ['panel'], + }); + + const hookResult = renderHook(() => useAdditionalBulkActions({ refetch: jest.fn() })); + + expect(hookResult.result.current).toEqual([ + { + id: 0, + items: ['item'], + }, + 'panel', + ]); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_additional_bulk_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_additional_bulk_actions.tsx new file mode 100644 index 0000000000000..10d63edbeea25 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_additional_bulk_actions.tsx @@ -0,0 +1,33 @@ +/* + * 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 { BulkActionsPanelConfig } from '@kbn/response-ops-alerts-table/types'; +import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'; + +export interface UseAdditionalBulkActionsParams { + /** + * Callback to refresh the table when alert tags are being applied + */ + refetch: () => void; +} + +/** + * Hook that returns a list of action items and their respective panels when necessary. + * The result is passed to the `additionalBulkActions` property of the ResponseOps alerts table. + * These will be displayed in the Alert summary page table. + */ +export const useAdditionalBulkActions = ({ + refetch, +}: UseAdditionalBulkActionsParams): BulkActionsPanelConfig[] => { + const { alertTagsItems, alertTagsPanels } = useBulkAlertTagsItems({ refetch }); + + return useMemo( + () => [{ id: 0, items: alertTagsItems }, ...alertTagsPanels], + [alertTagsItems, alertTagsPanels] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.test.ts new file mode 100644 index 0000000000000..92581a9c1f2e3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { useFetchIntegrations } from './use_fetch_integrations'; +import { installationStatuses, useGetPackagesQuery } from '@kbn/fleet-plugin/public'; + +jest.mock('@kbn/fleet-plugin/public'); + +describe('useFetchIntegrations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return isLoading true', () => { + (useGetPackagesQuery as jest.Mock).mockReturnValue({ + data: [], + isLoading: true, + }); + + const { result } = renderHook(() => useFetchIntegrations()); + + expect(result.current.availablePackages).toHaveLength(0); + expect(result.current.installedPackages).toHaveLength(0); + expect(result.current.isLoading).toBe(true); + }); + + it('should return availablePackages and installedPackages', () => { + (useGetPackagesQuery as jest.Mock).mockReturnValue({ + data: { + items: [ + { + name: 'splunk', + status: installationStatuses.Installed, + }, + { + name: 'google_secops', + status: installationStatuses.InstallFailed, + }, + { + name: 'microsoft_sentinel', + status: installationStatuses.NotInstalled, + }, + { name: 'unknown' }, + ], + }, + isLoading: false, + }); + + const { result } = renderHook(() => useFetchIntegrations()); + + expect(result.current.availablePackages).toHaveLength(1); + expect(result.current.availablePackages[0].name).toBe('microsoft_sentinel'); + + expect(result.current.installedPackages).toHaveLength(2); + expect(result.current.installedPackages[0].name).toBe('splunk'); + expect(result.current.installedPackages[1].name).toBe('google_secops'); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.ts new file mode 100644 index 0000000000000..604f99913ecc9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.ts @@ -0,0 +1,78 @@ +/* + * 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 { installationStatuses, useGetPackagesQuery } from '@kbn/fleet-plugin/public'; + +// We hardcode these here for now as we currently do not have any other way to filter out all the unwanted integrations. +const AI_FOR_SOC_INTEGRATIONS = [ + 'splunk', // doesnt yet exist + 'google_secops', + 'microsoft_sentinel', + 'sentinel_one', + 'crowdstrike', +]; + +export interface UseFetchIntegrationsResult { + /** + * Is true while the data is loading + */ + isLoading: boolean; + /** + * The AI for SOC installed integrations (see list in the constant above) + */ + installedPackages: PackageListItem[]; + /** + * The AI for SOC not-installed integrations (see list in the constant above) + */ + availablePackages: PackageListItem[]; +} + +/** + * Fetches all integrations, then returns the installed and non-installed ones filtered with a list of + * hard coded AI for SOC integrations: + * - splunk + * - google_secops + * - microsoft_sentinel + * - sentinel_one + * - crowdstrike + */ +export const useFetchIntegrations = (): UseFetchIntegrationsResult => { + // TODO this might need to be revisited once the integration make it out of prerelease + // The issue will be that users will see prerelease versions and not the GA ones + const { data: allPackages, isLoading } = useGetPackagesQuery({ + prerelease: true, + }); + + const aiForSOCPackages: PackageListItem[] = useMemo( + () => (allPackages?.items || []).filter((pkg) => AI_FOR_SOC_INTEGRATIONS.includes(pkg.name)), + [allPackages] + ); + const availablePackages: PackageListItem[] = useMemo( + () => aiForSOCPackages.filter((pkg) => pkg.status === installationStatuses.NotInstalled), + [aiForSOCPackages] + ); + const installedPackages: PackageListItem[] = useMemo( + () => + aiForSOCPackages.filter( + (pkg) => + pkg.status === installationStatuses.Installed || + pkg.status === installationStatuses.InstallFailed + ), + [aiForSOCPackages] + ); + + return useMemo( + () => ({ + availablePackages, + installedPackages, + isLoading, + }), + [availablePackages, installedPackages, isLoading] + ); +}; 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..69f6e335ef428 --- /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,92 @@ +/* + * 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 { useGetIntegrationFromRuleId } from './use_get_integration_from_rule_id'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; + +describe('useGetIntegrationFromRuleId', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined integration when no matching rule is found', () => { + const packages: PackageListItem[] = []; + const ruleId = ''; + const rules: RuleResponse[] = []; + + const { result } = renderHook(() => useGetIntegrationFromRuleId({ packages, ruleId, rules })); + + expect(result.current.integration).toBe(undefined); + }); + + it('should return undefined integration when the rule does not have the expected related_integrations', () => { + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, + ]; + const ruleId = 'rule_id'; + const rules: RuleResponse[] = [ + { + id: 'rule_id', + related_integrations: [{ package: 'wrong_integrations' }], + } as RuleResponse, + ]; + + const { result } = renderHook(() => useGetIntegrationFromRuleId({ packages, ruleId, rules })); + + expect(result.current.integration).toBe(undefined); + }); + + it('should render a matching integration', () => { + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, + ]; + const ruleId = 'rule_id'; + const rules: RuleResponse[] = [ + { + id: 'rule_id', + related_integrations: [{ package: 'splunk' }], + } as RuleResponse, + ]; + + const { result } = renderHook(() => useGetIntegrationFromRuleId({ packages, ruleId, rules })); + + expect(result.current.integration).toEqual({ + description: '', + download: '', + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + path: '', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }); + }); +}); 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..0325b5867a1c9 --- /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,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 { useMemo } from 'react'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; + +const EMPTY_ARRAY: RuleResponse[] = []; + +export interface UseGetIntegrationFromRuleIdParams { + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Id of the rule. This should be the value from the signal.rule.id field + */ + ruleId: string | string[]; + /** + * Result from fetching all rules + */ + rules: RuleResponse[] | undefined; +} + +export interface UseGetIntegrationFromRuleIdResult { + /** + * List of integrations ready to be consumed by the IntegrationFilterButton component + */ + integration: PackageListItem | undefined; +} + +/** + * Hook that returns a package (integration) from a ruleId (value for the signal.rule.id field), a list of rules and packages. + * 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 = ({ + packages, + ruleId, + rules = EMPTY_ARRAY, +}: UseGetIntegrationFromRuleIdParams): UseGetIntegrationFromRuleIdResult => { + // 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 = rules.find((r: RuleResponse) => r.id === signalRuleId); + if (!rule) { + return undefined; + } + + return packages.find((p) => rule.related_integrations.map((ri) => ri.package).includes(p.name)); + }, [packages, rules, ruleId]); + + return useMemo( + () => ({ + integration, + }), + [integration] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.test.ts new file mode 100644 index 0000000000000..b1dfc323f7dc6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react'; +import { useIntegrations } from './use_integrations'; +import { useKibana } from '../../../common/lib/kibana'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; +import { installationStatuses } from '@kbn/fleet-plugin/common/constants'; +import { FILTER_KEY } from '../../components/alert_summary/search_bar/integrations_filter_button'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; + +jest.mock('../../../common/lib/kibana'); + +describe('useIntegrations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return a checked integration', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + query: { + filterManager: { + getFilters: jest.fn().mockReturnValue([]), + }, + }, + }, + }, + }); + + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + path: '', + status: installationStatuses.Installed, + title: 'Splunk', + version: '', + }, + ]; + const ruleResponse = { + rules: [ + { + related_integrations: [{ package: 'splunk' }], + id: 'SplunkRuleId', + } as RuleResponse, + ], + isLoading: false, + }; + + const { result } = renderHook(() => useIntegrations({ packages, ruleResponse })); + + expect(result.current).toEqual({ + isLoading: false, + integrations: [ + { + checked: 'on', + 'data-test-subj': 'alert-summary-integration-option-Splunk', + key: 'SplunkRuleId', + label: 'Splunk', + }, + ], + }); + }); + + it('should return an un-checked integration', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + query: { + filterManager: { + getFilters: jest.fn().mockReturnValue([ + { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: FILTER_KEY, + params: { query: 'SplunkRuleId' }, + }, + query: { match_phrase: { [FILTER_KEY]: 'SplunkRuleId' } }, + }, + ]), + }, + }, + }, + }, + }); + + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + path: '', + status: installationStatuses.Installed, + title: 'Splunk', + version: '', + }, + ]; + const ruleResponse = { + rules: [ + { + related_integrations: [{ package: 'splunk' }], + id: 'SplunkRuleId', + } as RuleResponse, + ], + isLoading: false, + }; + + const { result } = renderHook(() => useIntegrations({ packages, ruleResponse })); + + expect(result.current).toEqual({ + isLoading: false, + integrations: [ + { + 'data-test-subj': 'alert-summary-integration-option-Splunk', + key: 'SplunkRuleId', + label: 'Splunk', + }, + ], + }); + }); + + it('should not return a integration if no rule match', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { query: { filterManager: { getFilters: jest.fn().mockReturnValue([]) } } }, + }, + }); + + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + path: '', + status: installationStatuses.Installed, + title: 'Splunk', + version: '', + }, + ]; + const ruleResponse = { + rules: [], + isLoading: false, + }; + + const { result } = renderHook(() => useIntegrations({ packages, ruleResponse })); + + expect(result.current).toEqual({ + isLoading: false, + integrations: [], + }); + }); + + it('should return isLoading true if rules are loading', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { query: { filterManager: { getFilters: jest.fn().mockReturnValue([]) } } }, + }, + }); + + const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + path: '', + status: installationStatuses.Installed, + title: 'Splunk', + version: '', + }, + ]; + const ruleResponse = { + rules: [], + isLoading: true, + }; + + const { result } = renderHook(() => useIntegrations({ packages, ruleResponse })); + + expect(result.current).toEqual({ + isLoading: true, + integrations: [], + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.ts new file mode 100644 index 0000000000000..7e5b4cd0748a9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.ts @@ -0,0 +1,107 @@ +/* + * 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 type { + EuiSelectableOption, + EuiSelectableOptionCheckedType, +} from '@elastic/eui/src/components/selectable/selectable_option'; +import { filterExistsInFiltersArray } from '../../utils/filter'; +import { useKibana } from '../../../common/lib/kibana'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; +import { FILTER_KEY } from '../../components/alert_summary/search_bar/integrations_filter_button'; + +export const INTEGRATION_OPTION_TEST_ID = 'alert-summary-integration-option-'; + +export interface UseIntegrationsParams { + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Result from the useQuery to fetch all rules + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; +} + +export interface UseIntegrationsResult { + /** + * List of integrations ready to be consumed by the IntegrationFilterButton component + */ + integrations: EuiSelectableOption[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; +} + +/** + * Combining installed packages and rules to create an interface that the IntegrationFilterButton can take as input (as EuiSelectableOption). + * If there is no match between a package and the rules, the integration is not returned. + * If a filter exists (we assume that this filter is negated) we do not mark the integration as checked for the EuiFilterButton. + */ +export const useIntegrations = ({ + packages, + ruleResponse, +}: UseIntegrationsParams): UseIntegrationsResult => { + const { + data: { + query: { filterManager }, + }, + } = useKibana().services; + + // There can be existing rules filtered out, coming when parsing the url + const currentFilters = filterManager.getFilters(); + + const integrations = useMemo(() => { + const result: EuiSelectableOption[] = []; + + packages.forEach((p: PackageListItem) => { + const matchingRule = ruleResponse.rules.find((r: RuleResponse) => + r.related_integrations.map((ri) => ri.package).includes(p.name) + ); + + if (matchingRule) { + // Retrieves the filter from the key/value pair + const currentFilter = filterExistsInFiltersArray( + currentFilters, + FILTER_KEY, + matchingRule.id + ); + + // A EuiSelectableOption is checked only if there is no matching filter for that rule + const integration = { + 'data-test-subj': `${INTEGRATION_OPTION_TEST_ID}${p.title}`, + ...(!currentFilter && { checked: 'on' as EuiSelectableOptionCheckedType }), + key: matchingRule?.id, // we save the rule id that we will match again the signal.rule.id field on the alerts + label: p.title, + }; + result.push(integration); + } + }); + + return result; + }, [currentFilters, packages, ruleResponse.rules]); + + return useMemo( + () => ({ + integrations, + isLoading: ruleResponse.isLoading, + }), + [integrations, ruleResponse.isLoading] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts new file mode 100644 index 0000000000000..0cb4ddcfda05c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { installationStatuses, useGetDataStreams } from '@kbn/fleet-plugin/public'; +import { useIntegrationsLastActivity } from './use_integrations_last_activity'; +import type { PackageListItem } from '@kbn/fleet-plugin/common'; + +jest.mock('@kbn/fleet-plugin/public'); + +const oldestLastActivity = 1735711200000; +const newestLastActivity = oldestLastActivity + 1000; + +const packages: PackageListItem[] = [ + { + description: '', + download: '', + id: 'splunk', + name: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + path: '', + status: installationStatuses.Installed, + title: 'Splunk', + version: '', + }, + { + description: '', + download: '', + id: 'google_secops', + name: 'google_secops', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + path: '', + status: installationStatuses.Installed, + title: 'Google SecOps', + version: '', + }, +]; + +describe('useIntegrationsLastActivity', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return isLoading true', () => { + (useGetDataStreams as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + }); + + const { result } = renderHook(() => useIntegrationsLastActivity({ packages })); + + expect(result.current.isLoading).toBe(true); + expect(result.current.lastActivities).toEqual({}); + }); + + it('should return an object with package name and last sync values', () => { + (useGetDataStreams as jest.Mock).mockReturnValue({ + data: { + data_streams: [{ package: 'splunk', last_activity_ms: oldestLastActivity }], + }, + isLoading: false, + }); + + const { result } = renderHook(() => useIntegrationsLastActivity({ packages })); + + expect(result.current.isLoading).toBe(false); + expect(result.current.lastActivities.splunk).toBe(oldestLastActivity); + }); + + it('should return most recent value for integration matching multiple dataStreams', () => { + (useGetDataStreams as jest.Mock).mockReturnValue({ + data: { + data_streams: [ + { package: 'splunk', last_activity_ms: oldestLastActivity }, + { package: 'splunk', last_activity_ms: newestLastActivity }, + { package: 'google_secops', last_activity_ms: oldestLastActivity }, + ], + }, + isLoading: false, + }); + + const { result } = renderHook(() => useIntegrationsLastActivity({ packages })); + + expect(result.current.isLoading).toBe(false); + expect(result.current.lastActivities.splunk).toBe(newestLastActivity); + expect(result.current.lastActivities.google_secops).toBe(oldestLastActivity); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts new file mode 100644 index 0000000000000..58ebefb1865d7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations_last_activity.ts @@ -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 { useMemo } from 'react'; +import type { DataStream, PackageListItem } from '@kbn/fleet-plugin/common'; +import { useGetDataStreams } from '@kbn/fleet-plugin/public'; + +export interface UseIntegrationsLastActivityParams { + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; +} + +export interface UseIntegrationsLastActivityResult { + /** + * Is true while the data is loading + */ + isLoading: boolean; + /** + * Object that stores each integration name/last activity values + */ + lastActivities: { [id: string]: number }; +} + +/** + * Fetches dataStreams, finds all the dataStreams for each integration, takes the value of the latest updated stream. + * Returns an object with the package name as the key and the last time it was synced (using data streams) as the value. + */ +export const useIntegrationsLastActivity = ({ + packages, +}: UseIntegrationsLastActivityParams): UseIntegrationsLastActivityResult => { + const { data, isLoading } = useGetDataStreams(); + + // Find all the matching dataStreams for our packages, take the most recently updated one for each package. + const lastActivities: { [id: string]: number } = useMemo(() => { + const la: { [id: string]: number } = {}; + packages.forEach((p: PackageListItem) => { + const dataStreams = (data?.data_streams || []).filter( + (d: DataStream) => d.package === p.name + ); + dataStreams.sort((a, b) => b.last_activity_ms - a.last_activity_ms); + const lastActivity = dataStreams.shift(); + + if (lastActivity) { + la[p.name] = lastActivity.last_activity_ms; + } + }); + return la; + }, [data, packages]); + + return useMemo( + () => ({ + isLoading, + lastActivities, + }), + [isLoading, lastActivities] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_navigate_to_integrations_page.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_navigate_to_integrations_page.test.ts new file mode 100644 index 0000000000000..d09ca2b65b418 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_navigate_to_integrations_page.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { + INTEGRATIONS_URL, + useNavigateToIntegrationsPage, +} from './use_navigate_to_integrations_page'; +import { useKibana, useNavigateTo } from '../../../common/lib/kibana'; + +jest.mock('../../../common/lib/kibana'); + +describe('useNavigateToIntegrationsPage', () => { + it('should return function', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: { + basePath: { + prepend: jest.fn().mockImplementation((url) => url), + }, + }, + }, + }); + const navigateTo = jest.fn(); + (useNavigateTo as jest.Mock).mockReturnValue({ navigateTo }); + + const { result } = renderHook(() => useNavigateToIntegrationsPage()); + + expect(typeof result.current).toBe('function'); + result.current(); + expect(navigateTo).toHaveBeenCalledWith({ + url: INTEGRATIONS_URL, + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_navigate_to_integrations_page.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_navigate_to_integrations_page.ts new file mode 100644 index 0000000000000..af4189a6337c4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_navigate_to_integrations_page.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { useKibana, useNavigateTo } from '../../../common/lib/kibana'; + +export const INTEGRATIONS_URL = '/app/security/configurations/integrations/browse'; + +/** + * Hook that returns a callback event to navigate to the AI4DSOC integrations page + */ +export const useNavigateToIntegrationsPage = (): (() => void) => { + const { + services: { + http: { + basePath: { prepend }, + }, + }, + } = useKibana(); + const { navigateTo } = useNavigateTo(); + + return useCallback(() => { + navigateTo({ url: prepend(INTEGRATIONS_URL) }); + }, [navigateTo, prepend]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts index 20b4e031e5478..29679805cd12c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/links.ts @@ -4,15 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { i18n } from '@kbn/i18n'; -import { ALERTS_PATH, SecurityPageName, SECURITY_FEATURE_ID } from '../../common/constants'; -import { ALERTS } from '../app/translations'; +import { + ALERT_SUMMARY_PATH, + ALERTS_PATH, + SECURITY_FEATURE_ID, + SecurityPageName, +} from '../../common/constants'; +import { ALERT_SUMMARY, ALERTS } from '../app/translations'; import type { LinkItem } from '../common/links/types'; -export const links: LinkItem = { - id: SecurityPageName.alerts, - title: ALERTS, - path: ALERTS_PATH, +export const alertsLink: LinkItem = { capabilities: [`${SECURITY_FEATURE_ID}.show`], globalNavPosition: 3, globalSearchKeywords: [ @@ -20,4 +23,21 @@ export const links: LinkItem = { defaultMessage: 'Alerts', }), ], + id: SecurityPageName.alerts, + path: ALERTS_PATH, + title: ALERTS, +}; + +export const alertSummaryLink: LinkItem = { + capabilities: [[`${SECURITY_FEATURE_ID}.show`, `${SECURITY_FEATURE_ID}.alerts_summary`]], + globalNavPosition: 3, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.alertSummary', { + defaultMessage: 'Alert summary', + }), + ], + hideTimeline: true, + id: SecurityPageName.alertSummary, + path: ALERT_SUMMARY_PATH, + title: ALERT_SUMMARY, }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/alert_summary.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/alert_summary.test.tsx new file mode 100644 index 0000000000000..8dbe827ca7c2c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/alert_summary.test.tsx @@ -0,0 +1,88 @@ +/* + * 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 { act, render } from '@testing-library/react'; +import { AlertSummaryPage, LOADING_INTEGRATIONS_TEST_ID } from './alert_summary'; +import { useFetchIntegrations } from '../../hooks/alert_summary/use_fetch_integrations'; +import { LANDING_PAGE_PROMPT_TEST_ID } from '../../components/alert_summary/landing_page/landing_page'; +import { useAddIntegrationsUrl } from '../../../common/hooks/use_add_integrations_url'; +import { DATA_VIEW_LOADING_PROMPT_TEST_ID } from '../../components/alert_summary/wrapper'; +import { useKibana } from '../../../common/lib/kibana'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; + +jest.mock('../../hooks/alert_summary/use_fetch_integrations'); +jest.mock('../../../common/hooks/use_add_integrations_url'); +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query'); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useFindRulesQuery as jest.Mock).mockReturnValue({ + isLoading: false, + data: { + rules: [ + { + related_integrations: [{ package: 'splunk' }], + id: 'SplunkRuleId', + }, + ], + total: 0, + }, + }); + }); + + it('should render loading logo', () => { + (useFetchIntegrations as jest.Mock).mockReturnValue({ + isLoading: true, + }); + + const { getByTestId } = render(); + expect(getByTestId(LOADING_INTEGRATIONS_TEST_ID)).toHaveTextContent('Loading integrations'); + }); + + it('should render landing page if no packages are installed', () => { + (useFetchIntegrations as jest.Mock).mockReturnValue({ + availablePackages: [{ id: 'id' }], + installedPackages: [], + isLoading: false, + }); + (useAddIntegrationsUrl as jest.Mock).mockReturnValue({ + onClick: jest.fn(), + }); + + const { getByTestId, queryByTestId } = render(); + expect(queryByTestId(LOADING_INTEGRATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(LANDING_PAGE_PROMPT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render wrapper if packages are installed', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + dataViews: { + create: jest.fn(), + }, + }, + }, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + availablePackages: [], + installedPackages: [{ id: 'id' }], + isLoading: false, + }); + + await act(async () => { + const { getByTestId, queryByTestId } = render(); + expect(queryByTestId(LOADING_INTEGRATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(LANDING_PAGE_PROMPT_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/alert_summary.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/alert_summary.tsx new file mode 100644 index 0000000000000..ad14707a6d644 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/alert_summary.tsx @@ -0,0 +1,61 @@ +/* + * 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 { EuiEmptyPrompt, EuiLoadingLogo } from '@elastic/eui'; +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { useFetchIntegrations } from '../../hooks/alert_summary/use_fetch_integrations'; +import { LandingPage } from '../../components/alert_summary/landing_page/landing_page'; +import { Wrapper } from '../../components/alert_summary/wrapper'; + +export const LOADING_INTEGRATIONS_TEST_ID = 'alert-summary-loading-integrations'; + +const LOADING_INTEGRATIONS = i18n.translate('xpack.securitySolution.alertSummary.loading', { + defaultMessage: 'Loading integrations', +}); + +/** + * Alert summary page rendering alerts generated by AI for SOC integrations. + * This page should be only rendered for the AI for SOC product line. + * It fetches all the rules and packages (integration) to pass them down to the rest of the page. + */ +export const AlertSummaryPage = memo(() => { + const { + availablePackages, + installedPackages, + isLoading: integrationIsLoading, + } = useFetchIntegrations(); + + // 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({}); + const ruleResponse = useMemo( + () => ({ + rules: data?.rules || [], + isLoading: ruleIsLoading, + }), + [data, ruleIsLoading] + ); + + if (integrationIsLoading) { + return ( + } + title={

{LOADING_INTEGRATIONS}

} + /> + ); + } + + if (installedPackages.length === 0) { + return ; + } + + return ; +}); + +AlertSummaryPage.displayName = 'AlertSummaryPage'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/index.tsx new file mode 100644 index 0000000000000..38bcdb2184f89 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alert_summary/index.tsx @@ -0,0 +1,32 @@ +/* + * 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 { Route, Routes } from '@kbn/shared-ux-router'; +import { SecurityRoutePageWrapper } from '../../../common/components/security_route_page_wrapper'; +import { AlertSummaryPage } from './alert_summary'; +import { NotFoundPage } from '../../../app/404'; +import { ALERT_SUMMARY_PATH, SecurityPageName } from '../../../../common/constants'; +import { PluginTemplateWrapper } from '../../../common/components/plugin_template_wrapper'; + +const AlertSummaryRoute = () => ( + + + + + +); + +export const AlertSummaryContainer: React.FC = React.memo(() => { + return ( + + + + + ); +}); +AlertSummaryContainer.displayName = 'AlertSummaryContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/routes.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/routes.tsx index afb2db8aa8e9e..aa5691593fa9a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/routes.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/routes.tsx @@ -6,9 +6,10 @@ */ import React from 'react'; -import type { RouteProps, RouteComponentProps } from 'react-router-dom'; +import type { RouteComponentProps, RouteProps } from 'react-router-dom'; import { Redirect } from 'react-router-dom'; -import { ALERTS_PATH, DETECTIONS_PATH } from '../../common/constants'; +import { AlertSummaryContainer } from './pages/alert_summary'; +import { ALERT_SUMMARY_PATH, ALERTS_PATH, DETECTIONS_PATH } from '../../common/constants'; import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; import { Alerts } from './pages/alerts'; @@ -34,4 +35,8 @@ export const routes: RouteProps[] = [ path: ALERTS_PATH, component: AlertsRoutes, }, + { + path: ALERT_SUMMARY_PATH, + component: AlertSummaryContainer, + }, ]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/filter.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/filter.test.ts new file mode 100644 index 0000000000000..f34dc0b6902a6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/filter.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { Filter, PhraseFilter } from '@kbn/es-query'; +import { filterExistsInFiltersArray, FilterIn, FilterOut, updateFiltersArray } from './filter'; + +describe('filterExistsInFiltersArray', () => { + it('should return false if empty array', () => { + const existingFilters: PhraseFilter[] = []; + const key: string = 'key'; + const value: string = 'value'; + + const doesFilterExists = filterExistsInFiltersArray(existingFilters, key, value); + + expect(doesFilterExists).toBe(undefined); + }); + + it('should return false if wrong filter', () => { + const key: string = 'key'; + const value: string = 'value'; + const filter = { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key, + params: { query: value }, + }, + query: { match_phrase: { [key]: value } }, + }; + const existingFilters: PhraseFilter[] = [filter]; + const wrongKey: string = 'wrongKey'; + const wrongValue: string = 'wrongValue'; + + const doesFilterExists = filterExistsInFiltersArray(existingFilters, wrongKey, wrongValue); + + expect(doesFilterExists).toBe(undefined); + }); + + it('should return true', () => { + const key: string = 'key'; + const value: string = 'value'; + const filter = { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key, + params: { query: value }, + }, + query: { match_phrase: { [key]: value } }, + }; + const existingFilters: PhraseFilter[] = [filter]; + + const doesFilterExists = filterExistsInFiltersArray(existingFilters, key, value); + + expect(doesFilterExists).toBe(filter); + }); +}); + +describe('updateFiltersArray', () => { + it('should add new filter', () => { + const existingFilters: PhraseFilter[] = []; + const key: string = 'key'; + const value: string = 'value'; + const filterType: boolean = FilterOut; + + const newFilters = updateFiltersArray( + existingFilters, + key, + value, + filterType + ) as PhraseFilter[]; + + expect(newFilters).toEqual([ + { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: 'key', + params: { query: 'value' }, + }, + query: { match_phrase: { key: 'value' } }, + }, + ]); + }); + + it(`should remove negated filter`, () => { + const key: string = 'key'; + const value: string = 'value'; + const existingFilters: Filter[] = [ + { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key, + params: { query: value }, + }, + query: { match_phrase: { [key]: value } }, + }, + ]; + const filterType: boolean = FilterIn; + + const newFilters = updateFiltersArray( + existingFilters, + key, + value, + filterType + ) as PhraseFilter[]; + + expect(newFilters).toHaveLength(0); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/filter.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/filter.ts new file mode 100644 index 0000000000000..d93f9d94d6bad --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/filter.ts @@ -0,0 +1,92 @@ +/* + * 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 { Filter } from '@kbn/es-query'; + +export const FilterIn = true; +export const FilterOut = false; + +/** + * Creates a new filter to apply to the KQL bar. + * + * @param key A string value mainly representing the field of an indicator + * @param value A string value mainly representing the value of the indicator for the field + * @param negate Set to true when we create a negated filter (e.g. NOT threat.indicator.type: url) + * @returns The new {@link Filter} + */ +const createFilter = ({ + key, + value, + negate, +}: { + key: string; + value: string; + negate: boolean; + index?: string; +}): Filter => ({ + meta: { + alias: null, + negate, + disabled: false, + type: 'phrase', + key, + params: { query: value }, + }, + query: { match_phrase: { [key]: value } }, +}); + +/** + * Checks if the key/value pair already exists in an array of filters. + * + * @param filters Array of {@link Filter} retrieved from the SearchBar filterManager. + * @param key A string value mainly representing the field of an indicator + * @param value A string value mainly representing the value of the indicator for the field + * @returns The new {@link Filter} + */ +export const filterExistsInFiltersArray = ( + filters: Filter[], + key: string, + value: string +): Filter | undefined => + filters.find( + (f: Filter) => + f.meta.key === key && + typeof f.meta.params === 'object' && + 'query' in f.meta.params && + f.meta.params?.query === value + ); + +/** + * Takes an array of filters and returns the updated array according to: + * - if the filter already exists, we remove it + * - if the filter does not exist, we add it + * This assumes that the only filters that can exist are negated filters. + * + * @param existingFilters List of {@link Filter} retrieved from the filterManager + * @param key The value used in the newly created {@link Filter} as a key + * @param value The value used in the newly created {@link Filter} as a params query + * @param filterType Weather the function is called for a {@link FilterIn} or {@link FilterOut} action + * @returns the updated array of filters + */ +export const updateFiltersArray = ( + existingFilters: Filter[], + key: string, + value: string | null, + filterType: boolean +): Filter[] => { + const newFilter = createFilter({ key, value: value as string, negate: !filterType }); + + const filter: Filter | undefined = filterExistsInFiltersArray( + existingFilters, + key, + value as string + ); + + return filter + ? existingFilters.filter((f: Filter) => f !== filter) + : [...existingFilters, newFilter]; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.test.tsx new file mode 100644 index 0000000000000..76eeaae083cb9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { Alert } from '@kbn/alerting-types'; +import { flattenAlertType } from './flatten_alert_type'; + +describe('flattenAlertType', () => { + it('should handle basic fields', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: ['value1'], + field2: [1], + }; + + const result = flattenAlertType(alert); + + expect(result).toEqual({ + _id: ['_id'], + _index: ['_index'], + field1: ['value1'], + field2: ['1'], + }); + }); + + it('should handle nested fields', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + 'kibana.alert.rule.parameters': [ + { + field1: 'value1', + field2: 1, + field3: ['value3', 'value3bis', 'value3ter'], + field4: false, + field5: { + field6: 'value6', + }, + }, + ], + }; + + const result = flattenAlertType(alert); + + expect(result).toEqual({ + _id: ['_id'], + _index: ['_index'], + 'kibana.alert.rule.parameters.field1': ['value1'], + 'kibana.alert.rule.parameters.field2': ['1'], + 'kibana.alert.rule.parameters.field3': ['value3', 'value3bis', 'value3ter'], + 'kibana.alert.rule.parameters.field4': ['false'], + 'kibana.alert.rule.parameters.field5.field6': ['value6'], + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.ts new file mode 100644 index 0000000000000..1c995323c5ed0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.ts @@ -0,0 +1,90 @@ +/* + * 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 { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; +import type { Alert } from '@kbn/alerting-types'; +import type { JsonValue } from '@kbn/utility-types'; +import { toObjectArrayOfStrings } from '@kbn/timelines-plugin/common'; + +const nonFlattenedFormatParamsFields = ['related_integrations', 'threat_mapping']; + +/** + * Returns true if the field is related to kibana.alert.rule.parameters. + * This code is similar to x-pack/platform/plugins/shared/timelines/common/utils/field_formatters.ts and once + * the Security Solution and Timelines plugins are merged we should probably share the code. + */ +const isRuleParametersFieldOrSubfield = ( + /** + * Field to check against + */ + field: string, + /** + * Optional value used if we're processing nested fields + */ + prependField?: string +) => + (prependField?.includes(ALERT_RULE_PARAMETERS) || field === ALERT_RULE_PARAMETERS) && + !nonFlattenedFormatParamsFields.includes(field); + +/** + * Recursive function that processes all the fields from an Alert and returns a flattened object as a Record. + * This is used in the AI for SOC alert summary page, in the getPromptContext when passing data to the assistant. + * The logic is similar to x-pack/platform/plugins/shared/timelines/common/utils/field_formatters.ts but for an Alert type. + */ +export const flattenAlertType = ( + /** + * Object of type Alert that needs nested fields flattened + */ + obj: Alert, + /** + * Parent field (populated when the function is called recursively on the nested fields) + */ + prependField?: string +): Record => { + const resultMap: Record = {}; + const allFields: string[] = Object.keys(obj); + + for (let i = 0; i < allFields.length; i++) { + const field: string = allFields[i]; + const value: string | number | JsonValue[] = obj[field]; + + const dotField: string = prependField ? `${prependField}.${field}` : field; + + const valueIntoObjectArrayOfStrings = toObjectArrayOfStrings(value); + const valueAsStringArray = valueIntoObjectArrayOfStrings.map(({ str }) => str); + const valueIsObjectArray = valueIntoObjectArrayOfStrings.some((o) => o.isObjectArray); + + if (!valueIsObjectArray) { + // Handle simple fields + resultMap[dotField] = valueAsStringArray; + } else { + // Process nested fields + const isRuleParameters = isRuleParametersFieldOrSubfield(field, prependField); + + const subField: string | undefined = isRuleParameters ? dotField : undefined; + const subValue: JsonValue = Array.isArray(value) ? value[0] : value; + + const subValueIntoObjectArrayOfStrings = toObjectArrayOfStrings(subValue); + const subValueAsStringArray = subValueIntoObjectArrayOfStrings.map(({ str }) => str); + const subValueIsObjectArray = subValueIntoObjectArrayOfStrings.some((o) => o.isObjectArray); + + if (!subValueIsObjectArray) { + resultMap[dotField] = subValueAsStringArray; + } else { + const nestedFieldValuePairs = flattenAlertType(subValue as Alert, subField); + const nestedFields = Object.keys(nestedFieldValuePairs); + + for (let j = 0; j < nestedFields.length; j++) { + const nestedField = nestedFields[j]; + resultMap[nestedField] = nestedFieldValuePairs[nestedField]; + } + } + } + } + + return resultMap; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.test.tsx new file mode 100644 index 0000000000000..2aa8b1f5c581d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { Alert } from '@kbn/alerting-types'; +import { getAlertFieldValueAsStringOrNull } from './get_alert_field_value_as_string_or_null'; + +describe('getAlertFieldValueAsStringOrNull', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'columnId'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toBe(null); + }); + + it('should handle string value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('value1'); + }); + + it('should handle a number value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 123, + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('123'); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('true, false'); + }); + + it('should handle array of numbers', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [1, 2], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('1, 2'); + }); + + it('should handle array of null', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [null, null], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual(', '); + }); + + it('should join array of JsonObjects', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [{ subField1: 'value1', subField2: 'value2' }], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('[object Object]'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.ts new file mode 100644 index 0000000000000..09b540d1c3cb1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.ts @@ -0,0 +1,41 @@ +/* + * 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 { JsonValue } from '@kbn/utility-types'; +import type { Alert } from '@kbn/alerting-types'; + +/** + * Takes an Alert object and a field string as input and returns the value for the field as a string. + * If the value is already a string, return it. + * If the value is an array, join the values. + * If null the value is null. + * Return the string of the value otherwise. + */ +export const getAlertFieldValueAsStringOrNull = (alert: Alert, field: string): string | null => { + const cellValues: string | number | JsonValue[] = alert[field]; + + if (typeof cellValues === 'string') { + return cellValues; + } else if (typeof cellValues === 'number') { + return cellValues.toString(); + } 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; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/type_utils.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/type_utils.test.tsx new file mode 100644 index 0000000000000..34ddf88fdb52a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/type_utils.test.tsx @@ -0,0 +1,250 @@ +/* + * 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 { Alert } from '@kbn/alerting-types'; +import { + getAlertFieldValueAsStringOrNull, + getAlertFieldValueAsStringOrNumberOrNull, + isJsonObjectValue, +} from './type_utils'; +import type { JsonValue } from '@kbn/utility-types'; + +describe('getAlertFieldValueAsStringOrNull', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'columnId'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toBe(null); + }); + + it('should handle string value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('value1'); + }); + + it('should handle a number value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 123, + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('123'); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('true, false'); + }); + + it('should handle array of numbers', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [1, 2], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('1, 2'); + }); + + it('should handle array of null', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [null, null], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual(', '); + }); + + it('should join array of JsonObjects', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [{ subField1: 'value1', subField2: 'value2' }], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('[object Object]'); + }); +}); + +describe('getAlertFieldValueAsStringOrNumberOrNull', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'columnId'; + + const result = getAlertFieldValueAsStringOrNumberOrNull(alert, field); + + expect(result).toBe(null); + }); + + it('should handle string value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNumberOrNull(alert, field); + + expect(result).toEqual('value1'); + }); + + it('should handle a number value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 123, + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNumberOrNull(alert, field); + + expect(result).toEqual(123); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNumberOrNull(alert, field); + + expect(result).toEqual('true'); + }); + + it('should handle array of numbers', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [1, 2], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNumberOrNull(alert, field); + + expect(result).toEqual(1); + }); + + it('should handle array of null', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [null, null], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNumberOrNull(alert, field); + + expect(result).toEqual(null); + }); + + it('should join array of JsonObjects', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [{ subField1: 'value1', subField2: 'value2' }], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNumberOrNull(alert, field); + + expect(result).toEqual('[object Object]'); + }); +}); + +describe('isJsonObjectValue', () => { + it('should return true for JsonObject', () => { + const value: JsonValue = { test: 'value' }; + + const result = isJsonObjectValue(value); + + expect(result).toBe(true); + }); + + it('should return false for null', () => { + const value: JsonValue = null; + + const result = isJsonObjectValue(value); + + expect(result).toBe(false); + }); + + it('should return false for string', () => { + const value: JsonValue = 'test'; + + const result = isJsonObjectValue(value); + + expect(result).toBe(false); + }); + + it('should return false for number', () => { + const value: JsonValue = 123; + + const result = isJsonObjectValue(value); + + expect(result).toBe(false); + }); + + it('should return false for boolean', () => { + const value: JsonValue = true; + + const result = isJsonObjectValue(value); + + expect(result).toBe(false); + }); + + it('should return false for array', () => { + const value: JsonValue = ['test', 123, true]; + + const result = isJsonObjectValue(value); + + expect(result).toBe(false); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/type_utils.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/type_utils.ts new file mode 100644 index 0000000000000..cf0b9fcaefb74 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/type_utils.ts @@ -0,0 +1,83 @@ +/* + * 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 { JsonObject, JsonValue } from '@kbn/utility-types'; +import type { Alert } from '@kbn/alerting-types'; + +/** + * Takes an Alert object and a field string as input and returns the value for the field as a string. + * If the value is already a string, return it. + * If the value is an array, join the values. + * If null the value is null. + * Return the string of the value otherwise. + */ +export const getAlertFieldValueAsStringOrNull = (alert: Alert, field: string): string | null => { + const cellValues: string | number | JsonValue[] = alert[field]; + + if (typeof cellValues === 'string') { + return cellValues; + } else if (typeof cellValues === 'number') { + return cellValues.toString(); + } 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; + } +}; + +/** + * Takes an Alert object and a field string as input and returns the value for the field as a string. + * If the value is already a number or a string, return it. + * If the value is an array, return the first value only. + * If null the value is null. + * Return the string of the value otherwise. + */ +export const getAlertFieldValueAsStringOrNumberOrNull = ( + alert: Alert, + field: string +): number | string | null => { + const cellValues: string | number | JsonValue[] = alert[field]; + + if (typeof cellValues === 'number' || typeof cellValues === 'string') { + return cellValues; + } else if (Array.isArray(cellValues)) { + const value: JsonValue = cellValues[0]; + if (typeof value === 'number' || typeof value === 'string') { + return value; + } else if (value == null) { + return null; + } else { + return value.toString(); + } + } else { + return null; + } +}; + +/** + * Guarantees that the value is of type JsonObject + */ +export const isJsonObjectValue = (value: JsonValue): value is JsonObject => { + return ( + value != null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + !Array.isArray(value) + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/header_title.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/header_title.test.tsx new file mode 100644 index 0000000000000..adaf3c1128bd5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/header_title.test.tsx @@ -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 React from 'react'; +import moment from 'moment-timezone'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { AIForSOCDetailsContext } from '../context'; +import { mockDataFormattedForFieldBrowser } from '../../document_details/shared/mocks/mock_data_formatted_for_field_browser'; +import { mockGetFieldsData } from '../../document_details/shared/mocks/mock_get_fields_data'; +import { + HEADER_INTEGRATION_TITLE_TEST_ID, + HEADER_RISK_SCORE_TITLE_TEST_ID, + HEADER_SEVERITY_TITLE_TEST_ID, + HEADER_SUMMARY_TEST_ID, + HEADER_TITLE_TEST_ID, + HeaderTitle, +} from './header_title'; +import { useDateFormat, useTimeZone } from '../../../common/lib/kibana'; +import { + RISK_SCORE_VALUE_TEST_ID, + SEVERITY_VALUE_TEST_ID, +} from '../../document_details/right/components/test_ids'; + +jest.mock('../../../common/lib/kibana'); + +moment.suppressDeprecationWarnings = true; +moment.tz.setDefault('UTC'); + +const dateFormat = 'MMM D, YYYY @ HH:mm:ss.SSS'; +const mockContextValue = { + dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, + getFieldsData: jest.fn().mockImplementation(mockGetFieldsData), +} as unknown as AIForSOCDetailsContext; + +const renderHeader = (contextValue: AIForSOCDetailsContext) => + render( + + + + + + ); + +describe('', () => { + beforeEach(() => { + jest.mocked(useDateFormat).mockImplementation(() => dateFormat); + jest.mocked(useTimeZone).mockImplementation(() => 'UTC'); + }); + + it('should render component', () => { + const { getByTestId } = renderHeader(mockContextValue); + + expect(getByTestId(`${HEADER_TITLE_TEST_ID}Text`)).toHaveTextContent('rule-name'); + expect(getByTestId(HEADER_SUMMARY_TEST_ID)).toBeInTheDocument(); + + expect(getByTestId(HEADER_SEVERITY_TITLE_TEST_ID)).toHaveTextContent('Severity'); + expect(getByTestId(SEVERITY_VALUE_TEST_ID)).toBeInTheDocument(); + + expect(getByTestId(HEADER_RISK_SCORE_TITLE_TEST_ID)).toHaveTextContent('Risk score'); + expect(getByTestId(RISK_SCORE_VALUE_TEST_ID)).toBeInTheDocument(); + + expect(getByTestId(HEADER_INTEGRATION_TITLE_TEST_ID)).toHaveTextContent('Integration'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/header_title.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/header_title.tsx new file mode 100644 index 0000000000000..8227f572b6e3b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/header_title.tsx @@ -0,0 +1,95 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { IntegrationIcon } from './integration_icon'; +import { DocumentSeverity } from '../../document_details/right/components/severity'; +import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data'; +import { FlyoutTitle } from '../../shared/components/flyout_title'; +import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; +import { getAlertTitle } from '../../document_details/shared/utils'; +import { RiskScore } from '../../document_details/right/components/risk_score'; +import { useAIForSOCDetailsContext } from '../context'; +import { AlertHeaderBlock } from '../../shared/components/alert_header_block'; + +export const HEADER_TITLE_TEST_ID = 'ai-for-soc-alert-flyout-header-title'; +export const HEADER_SUMMARY_TEST_ID = 'ai-for-soc-alert-flyout-header-summary'; +export const HEADER_SEVERITY_TITLE_TEST_ID = 'ai-for-soc-alert-flyout-header-severity'; +export const HEADER_RISK_SCORE_TITLE_TEST_ID = 'ai-for-soc-alert-flyout-header-risk-score'; +export const HEADER_INTEGRATION_TITLE_TEST_ID = 'ai-for-soc-alert-flyout-header-integration'; + +/** + * Header data for the AI for SOC for the alert summary flyout + */ +export const HeaderTitle = memo(() => { + const { dataFormattedForFieldBrowser, getFieldsData } = useAIForSOCDetailsContext(); + const { ruleId, ruleName, timestamp } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const title = useMemo(() => getAlertTitle({ ruleName }), [ruleName]); + + const date = useMemo(() => new Date(timestamp), [timestamp]); + + return ( + <> + {timestamp && } + + + + + + + + } + > + + + + + + } + > + + + + + + } + > + + + + + + + ); +}); + +HeaderTitle.displayName = 'HeaderTitle'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/integration_icon.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/integration_icon.test.tsx new file mode 100644 index 0000000000000..3a0086f05cf3d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/integration_icon.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 { render } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import React from 'react'; +import { useFetchIntegrations } from '../../../detections/hooks/alert_summary/use_fetch_integrations'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { useGetIntegrationFromRuleId } from '../../../detections/hooks/alert_summary/use_get_integration_from_rule_id'; +import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks'; +import { INTEGRATION_TEST_ID, IntegrationIcon } from './integration_icon'; +import { + INTEGRATION_ICON_TEST_ID, + INTEGRATION_LOADING_SKELETON_TEST_ID, +} from '../../../detections/components/alert_summary/common/integration_icon'; + +jest.mock('../../../detections/hooks/alert_summary/use_fetch_integrations'); +jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query'); +jest.mock('../../../detections/hooks/alert_summary/use_get_integration_from_rule_id'); +jest.mock('@kbn/fleet-plugin/public/hooks'); + +const LOADING_SKELETON_TEST_ID = `${INTEGRATION_TEST_ID}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`; +const ICON_TEST_ID = `${INTEGRATION_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`; + +describe('IntegrationIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return a single integration icon', () => { + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: [], + isLoading: false, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: false, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: { + title: 'title', + icons: [{ type: 'type', src: 'src' }], + name: 'name', + version: 'version', + }, + }); + (usePackageIconType as jest.Mock).mockReturnValue('iconType'); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument(); + }); + + it('should return the loading skeleton is rules are loading', () => { + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: [], + isLoading: true, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: false, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: {}, + }); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId(LOADING_SKELETON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should return the loading skeleton is integrations are loading', () => { + (useFindRulesQuery as jest.Mock).mockReturnValue({ + data: [], + isLoading: false, + }); + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + isLoading: true, + }); + (useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ + integration: {}, + }); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId(LOADING_SKELETON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/integration_icon.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/integration_icon.tsx new file mode 100644 index 0000000000000..125a964d549f1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/integration_icon.tsx @@ -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 React, { memo } from 'react'; +import { IntegrationIcon as Icon } from '../../../detections/components/alert_summary/common/integration_icon'; +import { useFetchIntegrations } from '../../../detections/hooks/alert_summary/use_fetch_integrations'; +import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; +import { useGetIntegrationFromRuleId } from '../../../detections/hooks/alert_summary/use_get_integration_from_rule_id'; + +export const INTEGRATION_TEST_ID = 'alert-summary-flyout'; + +interface IntegrationIconProps { + /** + * Id of the rule the alert was generated by + */ + ruleId: string; +} + +/** + * Renders the icon for the integration that matches the rule id. + * It fetches all the rules and packages (integrations) to find the matching by 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 IntegrationIcon = memo(({ ruleId }: IntegrationIconProps) => { + // 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(); + + const { integration } = useGetIntegrationFromRuleId({ + packages: installedPackages, + rules: data?.rules, + ruleId, + }); + + return ( + + ); +}); + +IntegrationIcon.displayName = 'IntegrationIcon'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.test.tsx new file mode 100644 index 0000000000000..103742dc56864 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 { useKibana } from '../../../common/lib/kibana'; +import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; +import { TAKE_ACTION_BUTTON_TEST_ID, TakeActionButton } from './take_action_button'; +import { useAlertsPrivileges } from '../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { useAIForSOCDetailsContext } from '../context'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); +jest.mock('../context'); + +describe('TakeActionButton', () => { + it('should render component with all options', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + ...mockCasesContract(), + helpers: { + canUseCases: jest.fn().mockReturnValue({ + read: true, + createComment: true, + }), + getRuleIdFromEvent: jest.fn(), + }, + }, + }, + }); + (useAIForSOCDetailsContext as jest.Mock).mockReturnValue({ + dataAsNestedObject: { + _id: '_id', + _index: '_index', + event: { kind: ['signal'] }, + kibana: { alert: { workflow_tags: [] } }, + }, + }); + + const { getByTestId } = render(); + + const button = getByTestId(TAKE_ACTION_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + + button.click(); + + expect(getByTestId('add-to-existing-case-action')).toBeInTheDocument(); + expect(getByTestId('add-to-new-case-action')).toBeInTheDocument(); + expect(getByTestId('alert-tags-context-menu-item')).toBeInTheDocument(); + }); + + it('should not show cases actions if user is not authorized', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + ...mockCasesContract(), + helpers: { + canUseCases: jest.fn().mockReturnValue({ + read: false, + createComment: false, + }), + getRuleIdFromEvent: jest.fn(), + }, + }, + }, + }); + (useAIForSOCDetailsContext as jest.Mock).mockReturnValue({ + dataAsNestedObject: { + _id: '_id', + _index: '_index', + event: { kind: ['signal'] }, + kibana: { alert: { workflow_tags: [] } }, + }, + }); + + const { getByTestId, queryByTestId } = render(); + + const button = getByTestId(TAKE_ACTION_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + + button.click(); + + expect(queryByTestId('add-to-existing-case-action')).not.toBeInTheDocument(); + expect(queryByTestId('add-to-new-case-action')).not.toBeInTheDocument(); + }); + + it('should not show tags actions if user is not authorized', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + ...mockCasesContract(), + helpers: { + canUseCases: jest.fn().mockReturnValue({ + read: true, + createComment: true, + }), + getRuleIdFromEvent: jest.fn(), + }, + }, + }, + }); + (useAIForSOCDetailsContext as jest.Mock).mockReturnValue({ + dataAsNestedObject: { + _id: '_id', + _index: '_index', + event: { kind: ['signal'] }, + kibana: { alert: { workflow_tags: [] } }, + }, + }); + + const { getByTestId, queryByTestId } = render(); + + const button = getByTestId(TAKE_ACTION_BUTTON_TEST_ID); + expect(button).toBeInTheDocument(); + + button.click(); + + expect(queryByTestId('alert-tags-context-menu-item')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.tsx new file mode 100644 index 0000000000000..ce353ed019b8b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.tsx @@ -0,0 +1,98 @@ +/* + * 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, useState } from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useAIForSOCDetailsContext } from '../context'; +import { useAddToCaseActions } from '../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions'; +import { useAlertTagsActions } from '../../../detections/components/alerts_table/timeline_actions/use_alert_tags_actions'; + +export const TAKE_ACTION_BUTTON_TEST_ID = 'alert-summary-flyout-take-action'; + +export const TAKE_ACTION_BUTTON = i18n.translate( + 'xpack.securitySolution.alertSummary.flyout.takeActionsAriaLabel', + { + defaultMessage: 'Take action', + } +); +export const ADD_TO_CASE_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.alertSummary.flyout.attachToCaseAriaLabel', + { + defaultMessage: 'Attach alert to case', + } +); + +/** + * Take action button in the panel footer. + * This is used in the AI for SOC alert summary page. + * The following options are available: + * - add to existing case + * - add to new case + * - apply alert tags + */ +export const TakeActionButton = memo(() => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const { dataAsNestedObject } = useAIForSOCDetailsContext(); + + const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const button = useMemo( + () => ( + + {TAKE_ACTION_BUTTON} + + ), + [togglePopover] + ); + + const { addToCaseActionItems } = useAddToCaseActions({ + ecsData: dataAsNestedObject, + onMenuItemClick: closePopover, + isActiveTimelines: false, + ariaLabel: ADD_TO_CASE_ARIA_LABEL, + isInDetections: true, + }); + + const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({ + closePopover, + ecsRowData: dataAsNestedObject, + }); + + const panels = useMemo( + () => [ + { + id: 0, + items: [...addToCaseActionItems, ...alertTagsItems], + }, + ...alertTagsPanels, + ], + [addToCaseActionItems, alertTagsItems, alertTagsPanels] + ); + + return ( + + + + ); +}); + +TakeActionButton.displayName = 'TakeActionButton'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/constants/panel_keys.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/constants/panel_keys.ts new file mode 100644 index 0000000000000..532cd1187481d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/constants/panel_keys.ts @@ -0,0 +1,8 @@ +/* + * 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 IOCPanelKey = 'ai-for-soc-details' as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/context.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/context.tsx new file mode 100644 index 0000000000000..690c4d67668b8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/context.tsx @@ -0,0 +1,126 @@ +/* + * 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, { createContext, memo, useContext, useMemo } from 'react'; +import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import type { SearchHit } from '../../../common/search_strategy'; +import type { GetFieldsData } from '../document_details/shared/hooks/use_get_fields_data'; +import { FlyoutLoading } from '../shared/components/flyout_loading'; +import { useEventDetails } from '../document_details/shared/hooks/use_event_details'; +import type { AIForSOCDetailsProps } from './types'; +import { FlyoutError } from '../shared/components/flyout_error'; + +export interface AIForSOCDetailsContext { + /** + * Id of the document + */ + eventId: string; + /** + * Name of the index used in the parent's page + */ + indexName: string; + /** + * An array of field objects with category and value + */ + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[]; + /** + * An object containing fields by type + */ + browserFields: BrowserFields; + /** + * An object with top level fields from the ECS object + */ + dataAsNestedObject: Ecs; + /** + * Retrieves searchHit values for the provided field + */ + getFieldsData: GetFieldsData; + /** + * The actual raw document object + */ + searchHit: SearchHit; +} + +/** + * A context provider for the AI for SOC alert summary flyout + */ +export const AIForSOCDetailsContext = createContext(undefined); + +export type AIForSOCDetailsProviderProps = { + /** + * React components to render + */ + children: React.ReactNode; +} & Partial; + +export const AIForSOCDetailsProvider = memo( + ({ id, indexName, children }: AIForSOCDetailsProviderProps) => { + const { + browserFields, + dataAsNestedObject, + dataFormattedForFieldBrowser, + getFieldsData, + loading, + searchHit, + } = useEventDetails({ + eventId: id, + indexName, + }); + const contextValue = useMemo( + () => + dataFormattedForFieldBrowser && dataAsNestedObject && id && indexName && searchHit + ? { + browserFields, + dataFormattedForFieldBrowser, + dataAsNestedObject, + eventId: id, + getFieldsData, + indexName, + searchHit, + } + : undefined, + [ + browserFields, + dataAsNestedObject, + dataFormattedForFieldBrowser, + getFieldsData, + id, + indexName, + searchHit, + ] + ); + + if (loading) { + return ; + } + + if (!contextValue) { + return ; + } + + return ( + + {children} + + ); + } +); + +AIForSOCDetailsProvider.displayName = 'AIForSOCDetailsProvider'; + +export const useAIForSOCDetailsContext = (): AIForSOCDetailsContext => { + const contextValue = useContext(AIForSOCDetailsContext); + + if (!contextValue) { + throw new Error( + 'AIForSOCDetailsContext can only be used within AIForSOCDetailsContext provider' + ); + } + + return contextValue; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/footer.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/footer.tsx new file mode 100644 index 0000000000000..22a40bb2729f1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/footer.tsx @@ -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 React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiFlyoutFooter, EuiPanel } from '@elastic/eui'; +import { NewChatByTitle } from '@kbn/elastic-assistant'; +import { i18n } from '@kbn/i18n'; +import { TakeActionButton } from './components/take_action_button'; +import { useAIForSOCDetailsContext } from './context'; +import { useBasicDataFromDetailsData } from '../document_details/shared/hooks/use_basic_data_from_details_data'; +import { useAssistant } from '../document_details/right/hooks/use_assistant'; + +export const ASK_AI_ASSISTANT = i18n.translate( + 'xpack.securitySolution.flyout.right.footer.askAIAssistant', + { + defaultMessage: 'Ask AI Assistant', + } +); + +export const FLYOUT_FOOTER_TEST_ID = 'ai-for-soc-alert-flyout-footer'; + +/** + * Bottom section of the flyout that contains the take action button + */ +export const PanelFooter = memo(() => { + const { dataFormattedForFieldBrowser } = useAIForSOCDetailsContext(); + const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const { showAssistant, showAssistantOverlay } = useAssistant({ + dataFormattedForFieldBrowser, + isAlert, + }); + + return ( + + + + {showAssistant && ( + + + + )} + + + + + + + ); +}); + +PanelFooter.displayName = 'PanelFooter'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/index.tsx new file mode 100644 index 0000000000000..f79b38d033a81 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/index.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { useAIForSOCDetailsContext } from './context'; +import { FlyoutBody } from '../shared/components/flyout_body'; +import { FlyoutNavigation } from '../shared/components/flyout_navigation'; +import type { AIForSOCDetailsProps } from './types'; +import { PanelFooter } from './footer'; +import { FlyoutHeader } from '../shared/components/flyout_header'; +import { HeaderTitle } from './components/header_title'; + +export const FLYOUT_BODY_TEST_ID = 'ai-for-soc-alert-flyout-body'; + +/** + * Panel to be displayed in AI for SOC alert summary flyout + */ +export const AIForSOCPanel: React.FC> = memo(() => { + const { eventId } = useAIForSOCDetailsContext(); + + return ( + <> + + + + + + <>{eventId} + + + + ); +}); +AIForSOCPanel.displayName = 'AIForSOCPanel'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/types.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/types.ts new file mode 100644 index 0000000000000..31da3cb69aee0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/types.ts @@ -0,0 +1,17 @@ +/* + * 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 { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import type { IOCPanelKey } from './constants/panel_keys'; + +export interface AIForSOCDetailsProps extends FlyoutPanelProps { + key: typeof IOCPanelKey; + params?: { + id: string; + indexName: string; + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx index b2d8e64c34b45..b5764d7d0f7d1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.test.tsx @@ -9,21 +9,24 @@ import React from 'react'; import { render } from '@testing-library/react'; import { DocumentDetailsContext } from '../../shared/context'; import { - RISK_SCORE_VALUE_TEST_ID, - SEVERITY_VALUE_TEST_ID, - FLYOUT_ALERT_HEADER_TITLE_TEST_ID, - STATUS_BUTTON_TEST_ID, ALERT_SUMMARY_PANEL_TEST_ID, - ASSIGNEES_TEST_ID, ASSIGNEES_EMPTY_TEST_ID, + ASSIGNEES_TEST_ID, + ASSIGNEES_TITLE_TEST_ID, + FLYOUT_ALERT_HEADER_TITLE_TEST_ID, NOTES_TITLE_TEST_ID, + RISK_SCORE_TITLE_TEST_ID, + RISK_SCORE_VALUE_TEST_ID, + SEVERITY_VALUE_TEST_ID, + STATUS_BUTTON_TEST_ID, + STATUS_TITLE_TEST_ID, } from './test_ids'; import { AlertHeaderTitle } from './alert_header_title'; import moment from 'moment-timezone'; import { useDateFormat, useTimeZone } from '../../../../common/lib/kibana'; import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; -import { TestProvidersComponent } from '../../../../common/mock'; +import { TestProviders } from '../../../../common/mock'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; jest.mock('../../../../common/lib/kibana'); @@ -41,11 +44,11 @@ const HEADER_TEXT_TEST_ID = `${FLYOUT_ALERT_HEADER_TITLE_TEST_ID}Text`; const renderHeader = (contextValue: DocumentDetailsContext) => render( - + - +
); describe('', () => { @@ -55,12 +58,19 @@ describe('', () => { }); it('should render component', () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + const { getByTestId, queryByTestId } = renderHeader(mockContextValue); expect(getByTestId(HEADER_TEXT_TEST_ID)).toHaveTextContent('rule-name'); expect(getByTestId(SEVERITY_VALUE_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ALERT_SUMMARY_PANEL_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(STATUS_TITLE_TEST_ID)).toHaveTextContent('Status'); + expect(getByTestId(RISK_SCORE_TITLE_TEST_ID)).toHaveTextContent('Risk score'); + expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toHaveTextContent('Assignees'); + expect(queryByTestId(NOTES_TITLE_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(RISK_SCORE_VALUE_TEST_ID)).toBeInTheDocument(); expect(getByTestId(STATUS_BUTTON_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ASSIGNEES_TEST_ID)).toBeInTheDocument(); @@ -81,6 +91,6 @@ describe('', () => { (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); const { getByTestId } = renderHeader(mockContextValue); - expect(getByTestId(NOTES_TITLE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(NOTES_TITLE_TEST_ID)).toHaveTextContent('Notes'); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx index 529e3d43b6056..2308e3338df74 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_title.tsx @@ -6,8 +6,9 @@ */ import React, { memo, useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLink } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import { FormattedMessage } from '@kbn/i18n-react'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { Notes } from './notes'; import { useRuleDetailsLink } from '../../shared/hooks/use_rule_details_link'; @@ -18,10 +19,16 @@ import { useRefetchByScope } from '../hooks/use_refetch_by_scope'; import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data'; import { useDocumentDetailsContext } from '../../shared/context'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; -import { FLYOUT_ALERT_HEADER_TITLE_TEST_ID, ALERT_SUMMARY_PANEL_TEST_ID } from './test_ids'; +import { + ALERT_SUMMARY_PANEL_TEST_ID, + ASSIGNEES_TITLE_TEST_ID, + FLYOUT_ALERT_HEADER_TITLE_TEST_ID, + RISK_SCORE_TITLE_TEST_ID, +} from './test_ids'; import { Assignees } from './assignees'; import { FlyoutTitle } from '../../../shared/components/flyout_title'; import { getAlertTitle } from '../../shared/utils'; +import { AlertHeaderBlock } from '../../../shared/components/alert_header_block'; // minWidth for each block, allows to switch for a 1 row 4 blocks to 2 rows with 2 block each const blockStyles = { @@ -78,9 +85,50 @@ export const AlertHeaderTitle = memo(() => { refetchFlyoutData(); }, [refetch, refetchFlyoutData]); + const riskScore = useMemo( + () => ( + + } + data-test-subj={RISK_SCORE_TITLE_TEST_ID} + > + + + ), + [getFieldsData] + ); + + const assignees = useMemo( + () => ( + + } + data-test-subj={ASSIGNEES_TITLE_TEST_ID} + > + + + ), + [alertAssignees, eventId, isPreview, onAssigneesUpdated] + ); + return ( <> - + {timestamp && } @@ -97,17 +145,8 @@ export const AlertHeaderTitle = memo(() => { - - - - - - + {riskScore} + {assignees} ) : ( { wrap data-test-subj={ALERT_SUMMARY_PANEL_TEST_ID} > - + - - - + {riskScore} - + - - - + {assignees} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx index 32e27b36e25ac..88ab099256a79 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx @@ -6,13 +6,9 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; -import { - ASSIGNEES_ADD_BUTTON_TEST_ID, - ASSIGNEES_EMPTY_TEST_ID, - ASSIGNEES_TITLE_TEST_ID, -} from './test_ids'; +import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_EMPTY_TEST_ID } from './test_ids'; import { Assignees } from './assignees'; import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile'; @@ -25,9 +21,9 @@ import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../../../common/components/as import { useLicense } from '../../../../common/hooks/use_license'; import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; import { + USER_AVATAR_ITEM_TEST_ID, USERS_AVATARS_COUNT_BADGE_TEST_ID, USERS_AVATARS_PANEL_TEST_ID, - USER_AVATAR_ITEM_TEST_ID, } from '../../../../common/components/user_profiles/test_ids'; import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; @@ -92,7 +88,6 @@ describe('', () => { it('should render component', () => { const { getByTestId } = renderAssignees(); - expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toBeInTheDocument(); expect(getByTestId(USERS_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).not.toBeDisabled(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx index aa8360c30d292..e17a0a5b34003 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx @@ -18,8 +18,6 @@ import { useGeneratedHtmlId, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { AlertHeaderBlock } from './alert_header_block'; import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { ASSIGNEES_PANEL_WIDTH } from '../../../../common/components/assignees/constants'; @@ -34,7 +32,6 @@ import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_EMPTY_TEST_ID, ASSIGNEES_TEST_ID, - ASSIGNEES_TITLE_TEST_ID, } from './test_ids'; const UpdateAssigneesButton: FC<{ @@ -79,8 +76,8 @@ export interface AssigneesProps { /** * Document assignees details displayed in flyout right section header */ -export const Assignees: FC = memo( - ({ eventId, assignedUserIds, onAssigneesUpdated, isPreview }) => { +export const Assignees = memo( + ({ eventId, assignedUserIds, onAssigneesUpdated, isPreview }: AssigneesProps) => { const isPlatinumPlus = useLicense().isPlatinumPlus(); const upsellingMessage = useUpsellingMessage('alert_assignments'); @@ -159,15 +156,7 @@ export const Assignees: FC = memo( ]); return ( - - } - data-test-subj={ASSIGNEES_TITLE_TEST_ID} - > + <> {isPreview ? (
{getEmptyTagValue()}
) : ( @@ -180,7 +169,7 @@ export const Assignees: FC = memo( {updateAssigneesPopover}
)} - + ); } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx index 32e85b786f6d2..04e2bebcd0213 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/event_header_title.tsx @@ -13,7 +13,7 @@ import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_f import { useDocumentDetailsContext } from '../../shared/context'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { FLYOUT_EVENT_HEADER_TITLE_TEST_ID } from './test_ids'; -import { getField, getEventTitle } from '../../shared/utils'; +import { getEventTitle, getField } from '../../shared/utils'; /** * Event details flyout right section header @@ -32,7 +32,7 @@ export const EventHeaderTitle = memo(() => { return ( <> - + {timestamp && } diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx index a0769828051ac..92afb8059cf15 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx @@ -8,16 +8,14 @@ import React from 'react'; import { render } from '@testing-library/react'; import { DocumentDetailsContext } from '../../shared/context'; -import { SHARE_BUTTON_TEST_ID, CHAT_BUTTON_TEST_ID } from './test_ids'; +import { SHARE_BUTTON_TEST_ID } from './test_ids'; import { HeaderActions } from './header_actions'; -import { useAssistant } from '../hooks/use_assistant'; import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { TestProvidersComponent } from '../../../../common/mock'; import { useGetFlyoutLink } from '../hooks/use_get_flyout_link'; jest.mock('../../../../common/lib/kibana'); -jest.mock('../hooks/use_assistant'); jest.mock('../hooks/use_get_flyout_link'); jest.mock('@elastic/eui', () => ({ @@ -52,11 +50,6 @@ describe('', () => { beforeEach(() => { window.location.search = '?'; jest.mocked(useGetFlyoutLink).mockReturnValue(alertUrl); - jest.mocked(useAssistant).mockReturnValue({ - showAssistantOverlay: jest.fn(), - showAssistant: true, - promptContextId: '', - }); }); describe('Share alert url action', () => { @@ -79,23 +72,5 @@ describe('', () => { }); expect(queryByTestId(SHARE_BUTTON_TEST_ID)).not.toBeInTheDocument(); }); - - it('should render chat button in the title', () => { - const { getByTestId } = renderHeaderActions(mockContextValue); - - expect(getByTestId(CHAT_BUTTON_TEST_ID)).toBeInTheDocument(); - }); - - it('should not render chat button in the title if should not be shown', () => { - jest.mocked(useAssistant).mockReturnValue({ - showAssistantOverlay: jest.fn(), - showAssistant: false, - promptContextId: '', - }); - - const { queryByTestId } = renderHeaderActions(mockContextValue); - - expect(queryByTestId(CHAT_BUTTON_TEST_ID)).not.toBeInTheDocument(); - }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx index 322b9cae1865a..84d60dfe76065 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx @@ -9,10 +9,8 @@ import type { VFC } from 'react'; import React, { memo } from 'react'; import { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { NewChatByTitle } from '@kbn/elastic-assistant'; import { useGetFlyoutLink } from '../hooks/use_get_flyout_link'; import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data'; -import { useAssistant } from '../hooks/use_assistant'; import { useDocumentDetailsContext } from '../../shared/context'; import { SHARE_BUTTON_TEST_ID } from './test_ids'; @@ -31,11 +29,6 @@ export const HeaderActions: VFC = memo(() => { const showShareAlertButton = isAlert && alertDetailsLink; - const { showAssistant, showAssistantOverlay } = useAssistant({ - dataFormattedForFieldBrowser, - isAlert, - }); - return ( { gutterSize="none" responsive={false} > - {showAssistant && ( - - - - )} {showShareAlertButton && ( { return ( - render( - - - - - - ); - describe('', () => { it('should render risk score information', () => { - const contextValue = { - getFieldsData: jest.fn().mockImplementation(mockGetFieldsData), - } as unknown as DocumentDetailsContext; + const getFieldsData = jest.fn().mockImplementation(mockGetFieldsData); - const { getByTestId } = renderRiskScore(contextValue); + const { getByTestId } = render( + + + + ); - expect(getByTestId(RISK_SCORE_TITLE_TEST_ID)).toBeInTheDocument(); const riskScore = getByTestId(RISK_SCORE_VALUE_TEST_ID); expect(riskScore).toBeInTheDocument(); expect(riskScore).toHaveTextContent('0'); }); it('should render empty component if missing getFieldsData value', () => { - const contextValue = { - getFieldsData: jest.fn(), - } as unknown as DocumentDetailsContext; + const getFieldsData = jest.fn(); - const { container } = renderRiskScore(contextValue); + const { container } = render( + + + + ); expect(container).toBeEmptyDOMElement(); }); it('should render empty component if getFieldsData is invalid', () => { - const contextValue = { - getFieldsData: jest.fn().mockImplementation(() => 123), - } as unknown as DocumentDetailsContext; + const getFieldsData = jest.fn().mockImplementation(() => 123); - const { container } = renderRiskScore(contextValue); + const { container } = render( + + + + ); expect(container).toBeEmptyDOMElement(); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/risk_score.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/risk_score.tsx index fba4343e97499..ee643e36cc3ff 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/risk_score.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/risk_score.tsx @@ -7,16 +7,20 @@ import React, { memo } from 'react'; import { ALERT_RISK_SCORE } from '@kbn/rule-data-utils'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { AlertHeaderBlock } from './alert_header_block'; -import { RISK_SCORE_TITLE_TEST_ID, RISK_SCORE_VALUE_TEST_ID } from './test_ids'; -import { useDocumentDetailsContext } from '../../shared/context'; +import type { GetFieldsData } from '../../shared/hooks/use_get_fields_data'; +import { RISK_SCORE_VALUE_TEST_ID } from './test_ids'; + +export interface RiskScoreProps { + /** + * Retrieves searchHit values for the provided field + */ + getFieldsData: GetFieldsData; +} /** * Document details risk score displayed in flyout right section header */ -export const RiskScore = memo(() => { - const { getFieldsData } = useDocumentDetailsContext(); +export const RiskScore = memo(({ getFieldsData }: RiskScoreProps) => { const fieldsData = getFieldsData(ALERT_RISK_SCORE); if (!fieldsData) { @@ -32,19 +36,7 @@ export const RiskScore = memo(() => { return null; } - return ( - - } - data-test-subj={RISK_SCORE_TITLE_TEST_ID} - > - {alertRiskScore} - - ); + return {alertRiskScore}; }); RiskScore.displayName = 'RiskScore'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.test.tsx index 5402f6f229671..b34da68d3b400 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.test.tsx @@ -7,29 +7,20 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { DocumentDetailsContext } from '../../shared/context'; import { SEVERITY_VALUE_TEST_ID } from './test_ids'; import { DocumentSeverity } from './severity'; import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { TestProviders } from '../../../../common/mock'; -const renderDocumentSeverity = (contextValue: DocumentDetailsContext) => - render( - - - - - - ); - describe('', () => { it('should render severity information', () => { - const contextValue = { - getFieldsData: jest.fn().mockImplementation(mockGetFieldsData), - scopeId: 'scopeId', - } as unknown as DocumentDetailsContext; + const getFieldsData = jest.fn().mockImplementation(mockGetFieldsData); - const { getByTestId } = renderDocumentSeverity(contextValue); + const { getByTestId } = render( + + + + ); const severity = getByTestId(SEVERITY_VALUE_TEST_ID); expect(severity).toBeInTheDocument(); @@ -37,34 +28,37 @@ describe('', () => { }); it('should render empty component if missing getFieldsData value', () => { - const contextValue = { - getFieldsData: jest.fn(), - scopeId: 'scopeId', - } as unknown as DocumentDetailsContext; + const getFieldsData = jest.fn(); - const { container } = renderDocumentSeverity(contextValue); + const { container } = render( + + + + ); expect(container).toBeEmptyDOMElement(); }); it('should render empty component if getFieldsData is invalid array', () => { - const contextValue = { - getFieldsData: jest.fn().mockImplementation(() => ['abc']), - scopeId: 'scopeId', - } as unknown as DocumentDetailsContext; + const getFieldsData = jest.fn().mockImplementation(() => ['abc']); - const { container } = renderDocumentSeverity(contextValue); + const { container } = render( + + + + ); expect(container).toBeEmptyDOMElement(); }); it('should render empty component if getFieldsData is invalid string', () => { - const contextValue = { - getFieldsData: jest.fn().mockImplementation(() => 'abc'), - scopeId: 'scopeId', - } as unknown as DocumentDetailsContext; + const getFieldsData = jest.fn().mockImplementation(() => 'abc'); - const { container } = renderDocumentSeverity(contextValue); + const { container } = render( + + + + ); expect(container).toBeEmptyDOMElement(); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.tsx index 7ae0d243d236f..1014c7ac0f74a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/severity.tsx @@ -5,41 +5,79 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import { upperFirst } from 'lodash/fp'; +import { EuiBadge, useEuiTheme } from '@elastic/eui'; +import { SEVERITY_VALUE_TEST_ID } from './test_ids'; +import type { GetFieldsData } from '../../shared/hooks/use_get_fields_data'; import { CellActions } from '../../shared/components/cell_actions'; -import { useDocumentDetailsContext } from '../../shared/context'; -import { SeverityBadge } from '../../../../common/components/severity_badge'; +import { useRiskSeverityColors } from '../../../../common/utils/risk_color_palette'; const isSeverity = (x: unknown): x is Severity => x === 'low' || x === 'medium' || x === 'high' || x === 'critical'; +export interface DocumentSeverityProps { + /** + * Retrieves searchHit values for the provided field + */ + getFieldsData: GetFieldsData; + /** + * If true, show cell actions to allow users to filter, toggle column, copy to clipboard... + * Default to false. + */ + showCellActions?: boolean; +} + /** * Document details severity displayed in flyout right section header */ -export const DocumentSeverity = memo(() => { - const { getFieldsData } = useDocumentDetailsContext(); - const fieldsData = getFieldsData(ALERT_SEVERITY); +export const DocumentSeverity = memo( + ({ getFieldsData, showCellActions = false }: DocumentSeverityProps) => { + const { euiTheme } = useEuiTheme(); - if (!fieldsData) { - return null; - } + const severityToColorMap = useRiskSeverityColors(); - let alertSeverity: Severity; - if (typeof fieldsData === 'string' && isSeverity(fieldsData)) { - alertSeverity = fieldsData; - } else if (Array.isArray(fieldsData) && fieldsData.length > 0 && isSeverity(fieldsData[0])) { - alertSeverity = fieldsData[0]; - } else { - return null; - } + const severity: Severity | null = useMemo(() => { + const fieldsData = getFieldsData(ALERT_SEVERITY); + + if (typeof fieldsData === 'string' && isSeverity(fieldsData)) { + return fieldsData; + } else if (Array.isArray(fieldsData) && fieldsData.length > 0 && isSeverity(fieldsData[0])) { + return fieldsData[0]; + } else { + return null; + } + }, [getFieldsData]); - return ( - - - - ); -}); + const displayValue = useMemo(() => (severity && upperFirst(severity)) ?? null, [severity]); + + const color = useMemo( + () => (severity && severityToColorMap[severity]) ?? euiTheme.colors.textSubdued, + [severity, euiTheme.colors.textSubdued, severityToColorMap] + ); + + return ( + <> + {severity && ( + <> + {showCellActions ? ( + + + {displayValue} + + + ) : ( + + {displayValue} + + )} + + )} + + ); + } +); DocumentSeverity.displayName = 'DocumentSeverity'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/status.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/status.tsx index 601201f2e58e8..f338db2495afd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/status.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/status.tsx @@ -9,7 +9,7 @@ import type { FC } from 'react'; import React, { useMemo } from 'react'; import { find } from 'lodash/fp'; import { FormattedMessage } from '@kbn/i18n-react'; -import { AlertHeaderBlock } from './alert_header_block'; +import { AlertHeaderBlock } from '../../../shared/components/alert_header_block'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { SIGNAL_STATUS_FIELD_NAME } from '../../../../timelines/components/timeline/body/renderers/constants'; import { StatusPopoverButton } from './status_popover_button'; @@ -52,6 +52,7 @@ export const DocumentStatus: FC = () => { return ( + render( + + + + + + ); describe('PanelFooter', () => { + beforeEach(() => { + jest.mocked(useAssistant).mockReturnValue({ + showAssistantOverlay: jest.fn(), + showAssistant: true, + promptContextId: '', + }); + }); + it('should not render the take action dropdown if preview mode', () => { - const { queryByTestId } = render( - - - - - - ); + const { queryByTestId } = renderPanelFooter(true); expect(queryByTestId(FLYOUT_FOOTER_TEST_ID)).not.toBeInTheDocument(); }); @@ -57,14 +71,27 @@ describe('PanelFooter', () => { }); (useAddToCaseActions as jest.Mock).mockReturnValue({ addToCaseActionItems: [] }); - const wrapper = render( - - - - - - ); - expect(wrapper.getByTestId(FLYOUT_FOOTER_TEST_ID)).toBeInTheDocument(); - expect(wrapper.getByTestId(FLYOUT_FOOTER_DROPDOWN_BUTTON_TEST_ID)).toBeInTheDocument(); + const { getByTestId } = renderPanelFooter(false); + + expect(getByTestId(FLYOUT_FOOTER_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(FLYOUT_FOOTER_DROPDOWN_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render chat button', () => { + const { getByTestId } = renderPanelFooter(false); + + expect(getByTestId(CHAT_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render chat button', () => { + jest.mocked(useAssistant).mockReturnValue({ + showAssistantOverlay: jest.fn(), + showAssistant: false, + promptContextId: '', + }); + + const { queryByTestId } = renderPanelFooter(true); + + expect(queryByTestId(CHAT_BUTTON_TEST_ID)).not.toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx index ce955a0b87ddc..b2226f5f144bc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx @@ -8,9 +8,21 @@ import type { FC } from 'react'; import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiFlyoutFooter, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NewChatByTitle } from '@kbn/elastic-assistant'; +import { useBasicDataFromDetailsData } from '../shared/hooks/use_basic_data_from_details_data'; +import { useDocumentDetailsContext } from '../shared/context'; +import { useAssistant } from './hooks/use_assistant'; import { FLYOUT_FOOTER_TEST_ID } from './test_ids'; import { TakeActionButton } from '../shared/components/take_action_button'; +export const ASK_AI_ASSISTANT = i18n.translate( + 'xpack.securitySolution.ai4soc.flyout.right.footer.askAIAssistant', + { + defaultMessage: 'Ask AI Assistant', + } +); + interface PanelFooterProps { /** * Boolean that indicates whether flyout is in preview and action should be hidden @@ -22,12 +34,24 @@ interface PanelFooterProps { * Bottom section of the flyout that contains the take action button */ export const PanelFooter: FC = ({ isPreview }) => { + const { dataFormattedForFieldBrowser } = useDocumentDetailsContext(); + const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const { showAssistant, showAssistantOverlay } = useAssistant({ + dataFormattedForFieldBrowser, + isAlert, + }); + if (isPreview) return null; return ( + {showAssistant && ( + + + + )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/index.tsx index 6ec67155e01a5..3e18f87bb6d73 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/index.tsx @@ -8,18 +8,22 @@ import React, { memo, useCallback } from 'react'; import { ExpandableFlyout, type ExpandableFlyoutProps } from '@kbn/expandable-flyout'; import { useEuiTheme } from '@elastic/eui'; +import type { AIForSOCDetailsProps } from './ai_for_soc/types'; +import { AIForSOCDetailsProvider } from './ai_for_soc/context'; +import { AIForSOCPanel } from './ai_for_soc'; import { SessionViewPanelProvider } from './document_details/session_view/context'; import type { SessionViewPanelProps } from './document_details/session_view'; import { SessionViewPanel } from './document_details/session_view'; import type { NetworkExpandableFlyoutProps } from './network_details'; +import { NetworkPanel, NetworkPanelKey, NetworkPreviewPanelKey } from './network_details'; import { Flyouts } from './document_details/shared/constants/flyouts'; import { + DocumentDetailsAlertReasonPanelKey, + DocumentDetailsAnalyzerPanelKey, DocumentDetailsIsolateHostPanelKey, DocumentDetailsLeftPanelKey, - DocumentDetailsRightPanelKey, DocumentDetailsPreviewPanelKey, - DocumentDetailsAlertReasonPanelKey, - DocumentDetailsAnalyzerPanelKey, + DocumentDetailsRightPanelKey, DocumentDetailsSessionViewPanelKey, } from './document_details/shared/constants/panel_keys'; import type { IsolateHostPanelProps } from './document_details/isolate_host'; @@ -43,14 +47,14 @@ import type { HostPanelExpandableFlyoutProps } from './entity_details/host_right import { HostPanel, HostPreviewPanelKey } from './entity_details/host_right'; import type { HostDetailsExpandableFlyoutProps } from './entity_details/host_details_left'; import { HostDetailsPanel, HostDetailsPanelKey } from './entity_details/host_details_left'; -import { NetworkPanel, NetworkPanelKey, NetworkPreviewPanelKey } from './network_details'; import type { AnalyzerPanelExpandableFlyoutProps } from './document_details/analyzer_panels'; import { AnalyzerPanel } from './document_details/analyzer_panels'; -import { UserPanelKey, HostPanelKey, ServicePanelKey } from './entity_details/shared/constants'; +import { HostPanelKey, ServicePanelKey, UserPanelKey } from './entity_details/shared/constants'; import type { ServicePanelExpandableFlyoutProps } from './entity_details/service_right'; import { ServicePanel } from './entity_details/service_right'; import type { ServiceDetailsExpandableFlyoutProps } from './entity_details/service_details_left'; import { ServiceDetailsPanel, ServiceDetailsPanelKey } from './entity_details/service_details_left'; +import { IOCPanelKey } from './ai_for_soc/constants/panel_keys'; /** * List of all panels that will be used within the document details expandable flyout. @@ -174,6 +178,14 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels'] ), }, + { + key: IOCPanelKey, + component: (props) => ( + + + + ), + }, ]; export const SECURITY_SOLUTION_ON_CLOSE_EVENT = `expandable-flyout-on-close-${Flyouts.securitySolution}`; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_block.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/alert_header_block.test.tsx similarity index 100% rename from x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_block.test.tsx rename to x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/alert_header_block.test.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_block.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/alert_header_block.tsx similarity index 72% rename from x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_block.tsx rename to x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/alert_header_block.tsx index fac083f90de5f..1cce848b71460 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/alert_header_block.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/alert_header_block.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import type { ReactElement, ReactNode, VFC } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; @@ -18,6 +18,12 @@ export interface AlertHeaderBlockProps { * React component to render as the value */ children: ReactNode; + /** + * If true, adds a slight 1px border on all edges. + * False by default. + * This is passed to the EuiPanel's hasBorder property. + */ + hasBorder?: boolean; /** * data-test-subj to use for the title */ @@ -27,9 +33,14 @@ export interface AlertHeaderBlockProps { /** * Reusable component for rendering a block with rounded edges, show a title and value below one another */ -export const AlertHeaderBlock: VFC = memo( - ({ title, children, 'data-test-subj': dataTestSubj }) => ( - +export const AlertHeaderBlock = memo( + ({ + title, + children, + hasBorder = false, + 'data-test-subj': dataTestSubj, + }: AlertHeaderBlockProps) => ( + diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx index 92ac1b6821781..f629aaf371bec 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx @@ -13,11 +13,12 @@ import { EuiFlexGroup, EuiFlexItem, type EuiIconProps, - useEuiTheme, EuiSkeletonText, + useEuiTheme, } from '@elastic/eui'; import type { FlyoutPanelHistory } from '@kbn/expandable-flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { IOCPanelKey } from '../../ai_for_soc/constants/panel_keys'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { DocumentDetailsRightPanelKey } from '../../document_details/shared/constants/panel_keys'; import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data'; @@ -29,11 +30,11 @@ import { useRuleDetails } from '../../rule_details/hooks/use_rule_details'; import { DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID, GENERIC_HISTORY_ROW_TEST_ID, + HISTORY_ROW_LOADING_TEST_ID, HOST_HISTORY_ROW_TEST_ID, NETWORK_HISTORY_ROW_TEST_ID, RULE_HISTORY_ROW_TEST_ID, USER_HISTORY_ROW_TEST_ID, - HISTORY_ROW_LOADING_TEST_ID, } from './test_ids'; import { HostPanelKey, UserPanelKey } from '../../entity_details/shared/constants'; @@ -56,6 +57,7 @@ export interface FlyoutHistoryRowProps { export const FlyoutHistoryRow: FC = memo(({ item, index }) => { switch (item.panel.id) { case DocumentDetailsRightPanelKey: + case IOCPanelKey: return ; case RulePanelKey: return ;