[AI4DSOC] Change the Attack Discovery page to use the AI for SOC alerts table (#218736)

## Summary

While testing, we realized that the Attack Discovery alerts tab was
showingn the `DetectionEngineAlertsTable`, even in the AI4DSOC tier.
This PR updates the logic to show the correct alerts table depending on
the tier:
- AI4DSOC will show the same table as the Alert summary page
- the other tiers will continue showing the same table as the Alerts
page (`DetectionEngineAlertsTable`)

Switching the table allows us to tackle at once all the other related
issues:
- wrong flyout was being shown
- too many actions were being shown
- wrong default columns, and wrong cell renderes

### Notes

The approach is not ideal. We shouldn't have to check for the following
```typescript
const AIForSOC = capabilities[SECURITY_FEATURE_ID].configurations;
```
in the code, but because of time constraints, this was the best
approach.
[A ticket](https://github.com/elastic/kibana/issues/218731) has been
opened to make sure we come back to this and implement the check the
correct way later.

Current (wrong) behavior


https://github.com/user-attachments/assets/c41a25f1-ae9a-4bbf-9c02-9b1054f3a0e3

New behavior


https://github.com/user-attachments/assets/0eb20a2f-ba00-42c0-9353-7ac788c9bea0

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Relates to https://github.com/elastic/security-team/issues/11973
This commit is contained in:
Philippe Oberti 2025-04-21 20:22:15 +02:00 committed by GitHub
parent 361d38acfc
commit c8cbe87040
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 532 additions and 26 deletions

View file

@ -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[] = [
{
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 ruleResponse = {
rules: [],
isLoading: false,
};
const id = 'id';
const query = { ids: { values: ['abcdef'] } };
describe('<Table />', () => {
it('should render all components', () => {
const { getByTestId } = render(
<TestProviders>
<Table
dataView={dataView}
id={id}
packages={packages}
query={query}
ruleResponse={ruleResponse}
/>
</TestProviders>
);
expect(getByTestId('alertsTableErrorPrompt')).toBeInTheDocument();
});
});

View file

@ -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, useMemo } 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 { AdditionalTableContext } from '../../../../../../../detections/components/alert_summary/table/table';
import {
ACTION_COLUMN_WIDTH,
ALERT_TABLE_CONSUMERS,
columns,
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';
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<QueryDslQueryContainer, 'bool' | 'ids'>;
/**
* 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, data, fieldFormats, http, licensing, notifications, settings },
} = useKibana();
const services = useMemo(
() => ({
data,
http,
notifications,
fieldFormats,
application,
licensing,
settings,
}),
[application, 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]
);
return (
<AlertsTable
actionsColumnWidth={ACTION_COLUMN_WIDTH}
additionalContext={additionalContext}
browserFields={browserFields}
columns={columns}
consumers={ALERT_TABLE_CONSUMERS}
gridStyle={GRID_STYLE}
id={id}
query={query}
renderActionsCell={ActionsCell}
renderCellValue={CellValue}
rowHeightsOptions={ROW_HEIGHTS_OPTIONS}
ruleTypeIds={RULE_TYPE_IDS}
services={services}
toolbarVisibility={TOOLBAR_VISIBILITY}
/>
);
});
Table.displayName = 'Table';

View file

@ -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 { act, render } from '@testing-library/react';
import {
AiForSOCAlertsTab,
CONTENT_TEST_ID,
ERROR_TEST_ID,
LOADING_PROMPT_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: () => <div />,
}));
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('<AiForSOCAlertsTab />', () => {
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() } },
},
});
await act(async () => {
const { getByTestId } = render(<AiForSOCAlertsTab id={id} query={query} />);
expect(getByTestId(LOADING_PROMPT_TEST_ID)).toBeInTheDocument();
expect(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,
});
await act(async () => {
const { getByTestId } = render(<AiForSOCAlertsTab id={id} query={query} />);
await new Promise(process.nextTick);
expect(getByTestId(LOADING_PROMPT_TEST_ID)).toBeInTheDocument();
expect(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()),
}));
await act(async () => {
const { getByTestId } = render(<AiForSOCAlertsTab id={id} query={query} />);
await new Promise(process.nextTick);
expect(getByTestId(LOADING_PROMPT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(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()),
}));
await act(async () => {
const { getByTestId } = render(
<TestProviders>
<AiForSOCAlertsTab id={id} query={query} />
</TestProviders>
);
await new Promise(process.nextTick);
expect(getByTestId(LOADING_PROMPT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(CONTENT_TEST_ID)).toBeInTheDocument();
});
});
});

View file

@ -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, useEffect, useMemo, useState } from 'react';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import { EuiEmptyPrompt, EuiSkeletonLoading, 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 LOADING_PROMPT_TEST_ID = 'attack-discovery-alert-loading-prompt';
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<QueryDslQueryContainer, 'bool' | 'ids'>;
}
/**
* 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<DataView | undefined>(undefined);
const [dataViewLoading, setDataViewLoading] = useState<boolean>(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 () => {
dv = await data.dataViews.create(dataViewSpec);
setDataView(dv);
setDataViewLoading(false);
};
createDataView();
// clearing after leaving the page
return () => {
if (dv?.id) {
data.dataViews.clearInstanceCache(dv?.id);
}
};
}, [data.dataViews]);
return (
<EuiSkeletonLoading
data-test-subj={LOADING_PROMPT_TEST_ID}
isLoading={integrationIsLoading || dataViewLoading}
loadingContent={
<EuiSkeletonRectangle data-test-subj={SKELETON_TEST_ID} height={400} width="100%" />
}
loadedContent={
<>
{!dataView || !dataView.id ? (
<EuiEmptyPrompt
color="danger"
data-test-subj={ERROR_TEST_ID}
iconType="error"
title={<h2>{DATAVIEW_ERROR}</h2>}
/>
) : (
<div data-test-subj={CONTENT_TEST_ID}>
<Table
dataView={dataView}
id={id}
packages={installedPackages}
query={query}
ruleResponse={ruleResponse}
/>
</div>
)}
</>
}
/>
);
});
AiForSOCAlertsTab.displayName = 'AiForSOCAlertsTab';

View file

@ -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: () => <div />,
}));
jest.mock('./ai_for_soc/wrapper', () => ({
AiForSOCAlertsTab: () => <div />,
}));
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(
<TestProviders>
<AlertsTab attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const alertsTab = screen.getByTestId('alertsTab');
expect(getByTestId('alertsTab')).toBeInTheDocument();
expect(getByTestId('detection-engine-alerts-table')).toBeInTheDocument();
});
expect(alertsTab).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(
<TestProviders>
<AlertsTab attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
expect(getByTestId('alertsTab')).toBeInTheDocument();
expect(getByTestId('ai4dsoc-alerts-table')).toBeInTheDocument();
});
});

View file

@ -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<Props> = ({ 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<Props> = ({ attackDiscovery, replacements })
[originalAlertIds]
);
const id = useMemo(() => `attack-discovery-alerts-${attackDiscovery.id}`, [attackDiscovery.id]);
return (
<div data-test-subj="alertsTab">
<DetectionEngineAlertsTable
id={`attack-discovery-alerts-${attackDiscovery.id}`}
tableType={TableId.alertsOnCasePage}
ruleTypeIds={SECURITY_SOLUTION_RULE_TYPE_IDS}
consumers={[AlertConsumers.SIEM]}
query={alertIdsQuery}
showAlertStatusWithFlapping={false}
/>
{AIForSOC ? (
<div data-test-subj="ai4dsoc-alerts-table">
<AiForSOCAlertsTab id={id} query={alertIdsQuery} />
</div>
) : (
<div data-test-subj="detection-engine-alerts-table">
<DetectionEngineAlertsTable
id={id}
tableType={TableId.alertsOnCasePage}
ruleTypeIds={SECURITY_SOLUTION_RULE_TYPE_IDS}
query={alertIdsQuery}
showAlertStatusWithFlapping={false}
/>
</div>
)}
</div>
);
};

View file

@ -39,24 +39,24 @@ import { buildTimeRangeFilter } from '../../alerts_table/helpers';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import type { RuleResponse } from '../../../../../common/api/detection_engine';
const TIMESTAMP_COLUMN = i18n.translate(
export const TIMESTAMP_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.timeStamp',
{ defaultMessage: 'Timestamp' }
);
const RELATION_INTEGRATION_COLUMN = i18n.translate(
export const RELATION_INTEGRATION_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.relatedIntegrationName',
{ defaultMessage: 'Integration' }
);
const SEVERITY_COLUMN = i18n.translate(
export const SEVERITY_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.severity',
{ defaultMessage: 'Severity' }
);
const RULE_NAME_COLUMN = i18n.translate(
export const RULE_NAME_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.ruleName',
{ defaultMessage: 'Rule' }
);
const columns: EuiDataGridProps['columns'] = [
export const columns: EuiDataGridProps['columns'] = [
{
id: TIMESTAMP,
displayAsText: TIMESTAMP_COLUMN,
@ -75,18 +75,18 @@ const columns: EuiDataGridProps['columns'] = [
},
];
const ACTION_COLUMN_WIDTH = 98; // px
const ALERT_TABLE_CONSUMERS: AlertsTableProps['consumers'] = [AlertConsumers.SIEM];
const RULE_TYPE_IDS = [ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID];
const ROW_HEIGHTS_OPTIONS = { defaultHeight: 40 };
const TOOLBAR_VISIBILITY: EuiDataGridToolBarVisibilityOptions = {
export const ACTION_COLUMN_WIDTH = 98; // 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,
};
const GRID_STYLE: EuiDataGridStyle = { border: 'horizontal' };
export const GRID_STYLE: EuiDataGridStyle = { border: 'horizontal' };
interface AdditionalTableContext {
export interface AdditionalTableContext {
/**
* List of installed AI for SOC integrations
*/