diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/table.test.tsx new file mode 100644 index 0000000000000..6c5ae0e1e241e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/table.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 { 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'; +import type { Filter, Query } from '@kbn/es-query'; + +const dataView: DataView = createStubDataView({ spec: {} }); +const packages: PackageListItem[] = [ + { + id: 'splunk', + icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }], + name: 'splunk', + status: installationStatuses.NotInstalled, + title: 'Splunk', + version: '0.1.0', + }, +]; +const query: Query = { + query: '', + language: '', +}; +const filters: Filter[] = []; +const from = ''; +const to = ''; +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/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/table.tsx new file mode 100644 index 0000000000000..89ec7c41a7962 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/table.tsx @@ -0,0 +1,192 @@ +/* + * 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 { + AlertsTableImperativeApi, + AlertsTableProps, +} from '@kbn/response-ops-alerts-table/types'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { getEsQueryConfig } from '@kbn/data-service'; +import type { Filter, Query } from '@kbn/es-query'; +import { buildTimeRangeFilter } from '../../../../../detections/components/alerts_table/helpers'; +import { combineQueries } from '../../../../../common/lib/kuery'; +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; + /** + * Filters passed from the rule details page. + * These contain the default filters (alerts, show building block, status and threat match) as well + * as the ones from the KQL bar. + */ + filters: Filter[]; + /** + * From value retrieved from the global KQL bar + */ + from: string; + /** + * List of installed AI for SOC integrations + */ + packages: PackageListItem[]; + /** + * Query retrieved from the global KQL bar + */ + query: Query; + /** + * Result from the useQuery to fetch the rule + */ + ruleResponse: { + /** + * Result from fetching all rules + */ + rules: RuleResponse[]; + /** + * True while rules are being fetched + */ + isLoading: boolean; + }; + /** + * To value retrieved from the global KQL bar + */ + to: string; +} + +/** + * 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, filters, from, packages, query, ruleResponse, to }: TableProps) => { + const { + services: { + application, + cases, + data, + fieldFormats, + http, + licensing, + notifications, + settings, + uiSettings, + }, + } = 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 timeRangeFilter = useMemo(() => buildTimeRangeFilter(from, to), [from, to]); + const finalFilters = useMemo( + () => [...filters, ...timeRangeFilter], + [filters, timeRangeFilter] + ); + + const finalQuery: AlertsTableProps['query'] = useMemo(() => { + const combinedQuery = combineQueries({ + config: getEsQueryConfig(uiSettings), + dataProviders: [], + dataViewSpec, + browserFields, + filters: finalFilters, + kqlQuery: query, + kqlMode: query.language, + }); + + if (combinedQuery?.kqlError || !combinedQuery?.filterQuery) { + return { bool: {} }; + } + + try { + const filter = JSON.parse(combinedQuery?.filterQuery); + return { bool: { filter } }; + } catch { + return { bool: {} }; + } + }, [browserFields, dataViewSpec, finalFilters, query, uiSettings]); + + 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/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/wrapper.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/wrapper.test.tsx new file mode 100644 index 0000000000000..039990041d94a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/wrapper.test.tsx @@ -0,0 +1,170 @@ +/* + * 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 type { Filter, Query } from '@kbn/es-query'; +import type { RuleResponse } from '../../../../../../common/api/detection_engine'; + +jest.mock('./table', () => ({ + Table: () =>
, +})); +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../detections/hooks/alert_summary/use_fetch_integrations'); + +const query: Query = { + query: '', + language: '', +}; +const filters: Filter[] = []; +const from = ''; +const to = ''; +const ruleResponse: RuleResponse = { + id: 'id', + name: 'name', + description: 'description', +} as RuleResponse; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useFetchIntegrations as jest.Mock).mockReturnValue({ + installedPackages: [], + 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/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/wrapper.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/wrapper.tsx new file mode 100644 index 0000000000000..3bd05ef5866fa --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/ai_for_soc/wrapper.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useEffect, useMemo, useState } from 'react'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { EuiEmptyPrompt, EuiSkeletonRectangle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { Filter, Query } from '@kbn/es-query'; +import type { RuleResponse } from '../../../../../../common/api/detection_engine'; +import { Table } from './table'; +import { useFetchIntegrations } from '../../../../../detections/hooks/alert_summary/use_fetch_integrations'; +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 { + /** + * Filters passed from the rule details page. + * These contain the default filters (alerts, show building block, status and threat match) as well + * as the ones from the KQL bar. + */ + filters: Filter[]; + /** + * From value retrieved from the global KQL bar + */ + from: string; + /** + * Query retrieved from the global KQL bar + */ + query: Query; + /** + * Result from the useQuery to fetch the rule + */ + rule: RuleResponse; + /** + * To value retrieved from the global KQL bar + */ + to: string; +} + +/** + * 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( + ({ filters, from, query, rule, to }: AiForSOCAlertsTableProps) => { + const { data } = useKibana().services; + const [dataView, setDataView] = useState(undefined); + const [dataViewLoading, setDataViewLoading] = useState(true); + + // Fetch all integrations + const { installedPackages, isLoading: integrationIsLoading } = useFetchIntegrations(); + + const ruleResponse = useMemo( + () => ({ + rules: [rule], + isLoading: false, + }), + [rule] + ); + + 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/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 08a6b066f331d..4998e36575be0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -39,6 +39,7 @@ import { TableId, } from '@kbn/securitysolution-data-table'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { AiForSOCAlertsTable } from './ai_for_soc/wrapper'; import { defaultGroupStatsAggregations, defaultGroupStatsRenderer, @@ -92,7 +93,7 @@ import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; import { SecurityPageName } from '../../../../app/types'; -import { APP_UI_ID } from '../../../../../common/constants'; +import { APP_UI_ID, SECURITY_FEATURE_ID } from '../../../../../common/constants'; import { useGlobalFullScreen } from '../../../../common/containers/use_full_screen'; import { Display } from '../../../../explore/hosts/pages/display'; @@ -214,10 +215,7 @@ const RuleDetailsPageComponent: React.FC = ({ analytics, i18n: i18nStart, theme, - application: { - navigateToApp, - capabilities: { actions }, - }, + application: { navigateToApp, capabilities }, timelines: timelinesUi, spaces: spacesApi, } = useKibana().services; @@ -317,6 +315,7 @@ const RuleDetailsPageComponent: React.FC = ({ // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); + const actions = capabilities.actions; const hasActionsPrivileges = useMemo(() => { if (rule?.actions != null && rule?.actions.length > 0 && isBoolean(actions.show)) { return actions.show; @@ -591,6 +590,10 @@ const RuleDetailsPageComponent: React.FC = ({ const isRuleEnabled = isExistingRule && (rule?.enabled ?? false); + // TODO We shouldn't have to check capabilities here, this should be done at a much higher level. + // https://github.com/elastic/kibana/issues/xxxxxx + const AIForSOC = Boolean(capabilities[SECURITY_FEATURE_ID].configurations); + return ( <> @@ -687,6 +690,7 @@ const RuleDetailsPageComponent: React.FC = ({ = ({ <> - - - - - {updatedAtValue} - - - - - - - {ruleId != null && ( - + {!AIForSOC ? ( + <> + + + + + {updatedAtValue} + + + + + + + {ruleId != null && ( + + )} + + ) : ( + <> + {ruleId != null && maybeRule != null && ( + + )} + )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index b1df44605515f..5ff33bcb847ed 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -16,7 +16,7 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { useScheduleRuleRun } from '../../../../detection_engine/rule_gaps/logic/use_schedule_rule_run'; import type { TimeRange } from '../../../../detection_engine/rule_gaps/types'; -import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; +import { APP_UI_ID, SECURITY_FEATURE_ID, SecurityPageName } from '../../../../../common/constants'; import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants'; import { BulkActionTypeEnum } from '../../../../../common/api/detection_engine/rule_management'; import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; @@ -70,9 +70,14 @@ const RuleActionsOverflowComponent = ({ }: RuleActionsOverflowComponentProps) => { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); const { - application: { navigateToApp }, + application: { navigateToApp, capabilities }, telemetry, } = 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/xxxxxx + const AIForSOC = Boolean(capabilities[SECURITY_FEATURE_ID].configurations); + const { startTransaction } = useStartTransaction(); const { executeBulkAction } = useExecuteBulkAction({ suppressSuccessToast: true }); const { bulkExport } = useBulkExport(); @@ -93,7 +98,7 @@ const RuleActionsOverflowComponent = ({ { startTransaction({ name: SINGLE_RULE_ACTIONS.DUPLICATE }); @@ -180,7 +185,7 @@ const RuleActionsOverflowComponent = ({ { closePopover(); @@ -204,6 +209,7 @@ const RuleActionsOverflowComponent = ({ ] : [], [ + AIForSOC, rule, canDuplicateRuleWithActions, userHasPermissions,