[AI4DSOC] Alert summary table setup (#216744)

## Summary

This PR adds the foundation for the table in the AI for SOC alerts
summary page.
These changes implement a new usage of the GroupedAlertTable component.
These are the functionalities implemented in this PR:
- default 3 options when opening the `Group alerts by` dropdown:
  - Integration: grouping by `signal.rule.id` field
  - Severity: grouping by `kibana.alert.severity`
  - Rule name: grouping by `kibana.alert.rule.name`
- we have custom group title renderer:
- for the group by Integration, we render the icon and the name of the
integration if found, or we fallback to the `signal.rule.id` value
  - for the others we use the same code as the default GroupedAlertTable
- we have custom group statistics:
  - for Integration we show severities, rules and alerts
  - for Severity we show integrations, rules and alerts
  - for Rules we show integrations, severities and alerts
- for everything else we show integrations, severities, rules and alerts

#### Here a video showing default grouping on the alert summary page


https://github.com/user-attachments/assets/43694969-8b43-4451-8f51-00622178ddf5

#### And another one showing custom fields and page refresh


https://github.com/user-attachments/assets/7b8d1047-4704-4149-a481-19721a381154

## Notes

Follow PRs will tackle custom column titles, cell renderers, row
actions... for the table (wip
[here](https://github.com/elastic/kibana/pull/217124)).

Mocks for reference:
https://www.figma.com/design/DYs7j4GQdAhg7aWTLI4R69/AI4DSOC?node-id=3284-69401&p=f&m=dev

## How to test

This needs to be ran in Serverless:
- `yarn es serverless --projectType security`
- `yarn serverless-security --no-base-path`

You also need to enable the AI for SOC tier, by adding the following to
your `serverless.security.dev.yaml` file:
```
xpack.securitySolutionServerless.productTypes:
  [
    { product_line: 'ai_soc', product_tier: 'search_ai_lake' },
  ]
```

Use one of these Serverless users:
- `platform_engineer`
- `endpoint_operations_analyst`
- `endpoint_policy_manager`
- `admin`
- `system_indices_superuser`

Then:
- generate data: `yarn test:generate:serverless-dev`
- create 4 catch all rules, each with a name of a AI for SOC integration
(`google_secops`, `microsoft_sentinel`,, `sentinel_one` and
`crowdstrike`)
- change [this
line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.ts#L73)
to `installedPackages: availablePackages` to force having some packages
installed
- change [this
line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.ts#L63)
to `r.name === p.name` to make sure there will be matches between
integrations and rules

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [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

https://github.com/elastic/security-team/issues/11973
This commit is contained in:
Philippe Oberti 2025-04-10 08:52:14 +02:00 committed by GitHub
parent c2de4d02cf
commit 579dbae6a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1733 additions and 34 deletions

View file

@ -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, TableEntityType> = {
[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),

View file

@ -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(
<TestProviders store={store}>
<AdditionalToolbarControls dataView={dataView} />
</TestProviders>
);
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,
});
});
});

View file

@ -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';

View file

@ -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',
},
},
},
]);
});
});

View file

@ -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;
};

View file

@ -0,0 +1,256 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getIntegrationComponent,
groupStatsRenderer,
Integration,
INTEGRATION_ICON_TEST_ID,
INTEGRATION_LOADING_TEST_ID,
} from './group_stats_renderers';
import type { GenericBuckets } from '@kbn/grouping/src';
import { render } from '@testing-library/react';
import React from 'react';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks';
jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id');
jest.mock('@kbn/fleet-plugin/public/hooks');
describe('Integration', () => {
it('should return a single integration icon', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: {
title: 'title',
icons: [{ type: 'type', src: 'src' }],
name: 'name',
version: 'version',
},
isLoading: false,
});
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
const bucket: GenericBuckets = { key: 'crowdstrike', doc_count: 10 };
const { getByTestId } = render(<Integration signalRuleIdBucket={bucket} />);
expect(getByTestId(INTEGRATION_ICON_TEST_ID)).toBeInTheDocument();
});
it('should return a single integration loading', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: {},
isLoading: true,
});
const bucket: GenericBuckets = { key: 'crowdstrike', doc_count: 10 };
const { getByTestId } = render(<Integration signalRuleIdBucket={bucket} />);
expect(getByTestId(INTEGRATION_LOADING_TEST_ID)).toBeInTheDocument();
});
});
describe('getIntegrationComponent', () => {
it('should return an empty array', () => {
const groupStatsItems = getIntegrationComponent({
key: '',
signalRuleIdSubAggregation: { buckets: [] },
doc_count: 2,
});
expect(groupStatsItems.length).toBe(0);
});
it('should return a single integration', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: { title: 'title', icons: 'icons', name: 'name', version: 'version' },
isLoading: false,
});
const groupStatsItems = getIntegrationComponent({
key: '',
signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 10 }] },
doc_count: 2,
});
expect(groupStatsItems.length).toBe(1);
expect(groupStatsItems[0].component).toMatchInlineSnapshot(`
<Memo(Integration)
signalRuleIdBucket={
Object {
"doc_count": 10,
"key": "crowdstrike",
}
}
/>
`);
});
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(`
<React.Fragment>
Multi
</React.Fragment>
`);
});
});
describe('groupStatsRenderer', () => {
it('should return array of badges for signal.rule.id field', () => {
const badges = groupStatsRenderer('signal.rule.id', {
key: '',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
rulesCountAggregation: { value: 3 },
doc_count: 10,
});
expect(badges.length).toBe(3);
expect(
badges.find(
(badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badges.find(
(badge) =>
badge.title === 'Rules:' &&
badge.component == null &&
badge.badge != null &&
badge.badge.value === 3
)
).toBeTruthy();
expect(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
badge.badge != null &&
badge.badge.value === 10
)
).toBeTruthy();
});
it('should return array of badges for kibana.alert.severity field', () => {
const badges = groupStatsRenderer('kibana.alert.severity', {
key: '',
signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 10 }] },
rulesCountAggregation: { value: 4 },
doc_count: 2,
});
expect(badges.length).toBe(3);
expect(
badges.find(
(badge) => badge.title === 'Integrations:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badges.find(
(badge) =>
badge.title === 'Rules:' &&
badge.component == null &&
badge.badge != null &&
badge.badge.value === 4
)
).toBeTruthy();
expect(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
badge.badge != null &&
badge.badge.value === 2
)
).toBeTruthy();
});
it('should return array of badges for kibana.alert.rule.name field', () => {
const badges = groupStatsRenderer('kibana.alert.rule.name', {
key: '',
signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 9 }] },
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 8 }] },
doc_count: 1,
});
expect(badges.length).toBe(3);
expect(
badges.find(
(badge) => badge.title === 'Integrations:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badges.find(
(badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
badge.badge != null &&
badge.badge.value === 1
)
).toBeTruthy();
});
it('should return default badges if the field does not exist', () => {
const badges = groupStatsRenderer('process.name', {
key: '',
signalRuleIdSubAggregation: { buckets: [{ key: 'crowdstrike', doc_count: 4 }] },
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 5 }] },
rulesCountAggregation: { value: 2 },
doc_count: 11,
});
expect(badges.length).toBe(4);
expect(
badges.find(
(badge) => badge.title === 'Integrations:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badges.find(
(badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badges.find(
(badge) =>
badge.title === 'Rules:' &&
badge.component == null &&
badge.badge != null &&
badge.badge.value === 2
)
).toBeTruthy();
expect(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
badge.badge != null &&
badge.badge.value === 11
)
).toBeTruthy();
});
});

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiSkeletonText } from '@elastic/eui';
import type { GroupStatsItem, RawBucket } from '@kbn/grouping';
import React, { memo } from 'react';
import { i18n } from '@kbn/i18n';
import type { GenericBuckets } from '@kbn/grouping/src';
import { CardIcon } from '@kbn/fleet-plugin/public';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import { getRulesBadge, getSeverityComponent } from '../../alerts_table/grouping_settings';
import { DEFAULT_GROUP_STATS_RENDERER } from '../../alerts_table/alerts_grouping';
import type { AlertsGroupingAggregation } from '../../alerts_table/grouping_settings/types';
const STATS_GROUP_SIGNAL_RULE_ID = i18n.translate(
'xpack.securitySolution.alertSummary.groups.integrations',
{
defaultMessage: 'Integrations:',
}
);
const STATS_GROUP_SIGNAL_RULE_ID_MULTI = i18n.translate(
'xpack.securitySolution.alertSummary.groups.integrations.multi',
{
defaultMessage: ' Multi',
}
);
export const INTEGRATION_ICON_TEST_ID = 'alert-summary-table-integration-cell-renderer-icon';
export const INTEGRATION_LOADING_TEST_ID = 'alert-summary-table-integration-cell-renderer-loading';
interface IntegrationProps {
/**
* Aggregation buckets for integrations
*/
signalRuleIdBucket: GenericBuckets;
}
/**
* Renders the icon for the integration that matches the rule id.
* In AI for SOC, we can retrieve the integration/package that matches a specific rule, via the related_integrations field on the rule.
*/
export const Integration = memo(({ signalRuleIdBucket }: IntegrationProps) => {
const signalRuleId = signalRuleIdBucket.key;
const { integration, isLoading } = useGetIntegrationFromRuleId({ ruleId: signalRuleId });
return (
<EuiSkeletonText data-test-subj={INTEGRATION_LOADING_TEST_ID} isLoading={isLoading} lines={1}>
{integration ? (
<CardIcon
data-test-subj={INTEGRATION_ICON_TEST_ID}
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
size="s"
version={integration.version}
/>
) : null}
</EuiSkeletonText>
);
});
Integration.displayName = 'Integration';
/**
* Return a renderer for integration aggregation.
*/
export const getIntegrationComponent = (
bucket: RawBucket<AlertsGroupingAggregation>
): GroupStatsItem[] => {
const signalRuleIds = bucket.signalRuleIdSubAggregation?.buckets;
if (!signalRuleIds || signalRuleIds.length === 0) {
return [];
}
if (signalRuleIds.length === 1) {
return [
{
title: STATS_GROUP_SIGNAL_RULE_ID,
component: <Integration signalRuleIdBucket={signalRuleIds[0]} />,
},
];
}
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<AlertsGroupingAggregation>
): 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];
}
};

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
groupTitleRenderers,
INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID,
INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID,
INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID,
INTEGRATION_GROUP_RENDERER_TEST_ID,
IntegrationNameGroupContent,
SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID,
} from './group_title_renderers';
import { render } from '@testing-library/react';
import { defaultGroupTitleRenderers } from '../../alerts_table/grouping_settings';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import React from 'react';
jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id');
describe('groupTitleRenderers', () => {
it('should render correctly for signal.rule.id field', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: { title: 'rule_name' },
isLoading: false,
});
const { getByTestId } = render(
groupTitleRenderers(
'signal.rule.id',
{
key: ['rule_id'],
doc_count: 10,
},
'This is a null group!'
)!
);
expect(getByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).toBeInTheDocument();
});
it('should render correctly for kibana.alert.rule.name field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'kibana.alert.rule.name',
{
key: ['Rule name test', 'Some description'],
doc_count: 10,
},
'This is a null group!'
)!
);
expect(getByTestId('rule-name-group-renderer')).toBeInTheDocument();
});
it('should render correctly for host.name field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'host.name',
{
key: 'Host',
doc_count: 2,
},
'This is a null group!'
)!
);
expect(getByTestId('host-name-group-renderer')).toBeInTheDocument();
});
it('should render correctly for user.name field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'user.name',
{
key: 'User test',
doc_count: 1,
},
'This is a null group!'
)!
);
expect(getByTestId('user-name-group-renderer')).toBeInTheDocument();
});
it('should render correctly for source.ip field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'source.ip',
{
key: 'sourceIp',
doc_count: 23,
},
'This is a null group!'
)!
);
expect(getByTestId('source-ip-group-renderer')).toBeInTheDocument();
});
it('should return undefined when the renderer does not exist', () => {
const wrapper = groupTitleRenderers(
'process.name',
{
key: 'process',
doc_count: 10,
},
'This is a null group!'
);
expect(wrapper).toBeUndefined();
});
});
describe('IntegrationNameGroupContent', () => {
it('should render the integration name and icon when a matching rule is found', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: { title: 'rule_name', icons: 'icon' },
isLoading: false,
});
const { getByTestId, queryByTestId } = render(<IntegrationNameGroupContent title="rule.id" />);
expect(getByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).toBeInTheDocument();
expect(getByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID)).toHaveTextContent(
'rule_name'
);
expect(getByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID)).not.toBeInTheDocument();
});
it('should render rule id when no matching rule is found', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: undefined,
isLoading: false,
});
const { getByTestId, queryByTestId } = render(<IntegrationNameGroupContent title="rule.id" />);
expect(getByTestId(SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID)).toHaveTextContent('rule.id');
expect(queryByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).not.toBeInTheDocument();
expect(
queryByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID)
).not.toBeInTheDocument();
expect(
queryByTestId(INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID)
).not.toBeInTheDocument();
});
it('should render loading for signal.rule.id field when rule and packages are loading', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: undefined,
isLoading: true,
});
const { getByTestId, queryByTestId } = render(<IntegrationNameGroupContent title="rule.id" />);
expect(getByTestId(INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(INTEGRATION_GROUP_RENDERER_TEST_ID)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiTitle } from '@elastic/eui';
import { isArray } from 'lodash/fp';
import React, { memo } from 'react';
import type { GroupPanelRenderer } from '@kbn/grouping/src';
import { CardIcon } from '@kbn/fleet-plugin/public';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import { GroupWithIconContent, RuleNameGroupContent } from '../../alerts_table/grouping_settings';
import type { AlertsGroupingAggregation } from '../../alerts_table/grouping_settings/types';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
/**
* Returns renderers to be used in the `buttonContent` property of the EuiAccordion component used within the kbn-grouping package.
* It handles custom renders for the following fields:
* - signal.rule.id
* - kibana.alert.rule.name
* - host.name
* - user.name
* - source.ip
* For all the other fields the default renderer managed within the kbn-grouping package will be used.
*
* These go hand in hand with groupingOptions, groupStatsRenderer and groupStatsAggregations.
*/
export const groupTitleRenderers: GroupPanelRenderer<AlertsGroupingAggregation> = (
selectedGroup,
bucket,
nullGroupMessage
) => {
switch (selectedGroup) {
case 'signal.rule.id':
return <IntegrationNameGroupContent title={bucket.key} />;
case 'kibana.alert.rule.name':
return isArray(bucket.key) ? (
<RuleNameGroupContent
ruleName={bucket.key[0]}
ruleDescription={
firstNonNullValue(firstNonNullValue(bucket.description?.buckets)?.key) ?? ''
}
tags={bucket.ruleTags?.buckets}
/>
) : undefined;
case 'host.name':
return (
<GroupWithIconContent
title={bucket.key}
icon="storage"
nullGroupMessage={nullGroupMessage}
dataTestSubj="host-name"
/>
);
case 'user.name':
return (
<GroupWithIconContent
title={bucket.key}
icon="user"
nullGroupMessage={nullGroupMessage}
dataTestSubj="user-name"
/>
);
case 'source.ip':
return (
<GroupWithIconContent
title={bucket.key}
icon="globe"
nullGroupMessage={nullGroupMessage}
dataTestSubj="source-ip"
/>
);
}
};
export const INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID = 'integration-group-renderer-loading';
export const INTEGRATION_GROUP_RENDERER_TEST_ID = 'integration-group-renderer';
export const INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID =
'integration-group-renderer-integration-name';
export const INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID =
'integration-group-renderer-integration-icon';
export const SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID = 'signal-rule-id-group-renderer';
/**
* Renders an icon and name of an integration.
*/
export const IntegrationNameGroupContent = memo<{
title: string | string[];
}>(({ title }) => {
const { integration, isLoading } = useGetIntegrationFromRuleId({ ruleId: title });
return (
<EuiSkeletonText
data-test-subj={INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID}
isLoading={isLoading}
lines={1}
>
{integration ? (
<EuiFlexGroup
data-test-subj={INTEGRATION_GROUP_RENDERER_TEST_ID}
gutterSize="s"
alignItems="center"
>
<EuiFlexItem grow={false}>
<CardIcon
data-test-subj={INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID}
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
size="xl"
version={integration.version}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle
data-test-subj={INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID}
size="xs"
>
<h5>{integration.title}</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiTitle data-test-subj={SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID} size="xs">
<h5>{title}</h5>
</EuiTitle>
)}
</EuiSkeletonText>
);
});
IntegrationNameGroupContent.displayName = 'IntegrationNameGroup';

View file

@ -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',
},
];

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 from 'react';
import { render } from '@testing-library/react';
import type { Alert } from '@kbn/alerting-types';
import { CellValue } from './render_cell';
import { TestProviders } from '../../../../common/mock';
import { getEmptyValue } from '../../../../common/components/empty_value';
describe('CellValue', () => {
it('should handle missing field', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
field1: 'value1',
};
const columnId = 'columnId';
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
</TestProviders>
);
expect(getByText(getEmptyValue())).toBeInTheDocument();
});
it('should handle string value', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
field1: 'value1',
};
const columnId = 'field1';
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
</TestProviders>
);
expect(getByText('value1')).toBeInTheDocument();
});
it('should handle array of booleans', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
field1: [true, false],
};
const columnId = 'field1';
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
</TestProviders>
);
expect(getByText('true, false')).toBeInTheDocument();
});
it('should handle array of numbers', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
field1: [1, 2],
};
const columnId = 'field1';
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
</TestProviders>
);
expect(getByText('1, 2')).toBeInTheDocument();
});
it('should handle array of null', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
field1: [null, null],
};
const columnId = 'field1';
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
</TestProviders>
);
expect(getByText(',')).toBeInTheDocument();
});
it('should join array of JsonObjects', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
field1: [{ subField1: 'value1', subField2: 'value2' }],
};
const columnId = 'field1';
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
</TestProviders>
);
expect(getByText('[object Object]')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import type { Alert } from '@kbn/alerting-types';
import type { JsonValue } from '@kbn/utility-types';
import { getOrEmptyTagFromValue } from '../../../../common/components/empty_value';
const styles = { display: 'flex', alignItems: 'center', height: '100%' };
export interface CellValueProps {
/**
* Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface
*/
alert: Alert;
/**
* Column id passed from the renderCellValue callback via EuiDataGridProps['renderCellValue'] interface
*/
columnId: string;
}
/**
* Component used in the AI for SOC alert summary table.
* It renders all the values currently as simply as possible (see code comments below).
* It will be soon improved to support custom renders for specific fields (like kibana.alert.rule.parameters and kibana.alert.severity).
*/
export const CellValue = memo(({ alert, columnId }: CellValueProps) => {
const displayValue: string | null = useMemo(() => {
const cellValues: string | JsonValue[] = alert[columnId];
// Displays string as is.
// Joins values of array with more than one element.
// Returns null if the value is null.
// Return the string of the value otherwise.
if (typeof cellValues === 'string') {
return cellValues;
} else if (Array.isArray(cellValues)) {
if (cellValues.length > 1) {
return cellValues.join(', ');
} else {
const value: JsonValue = cellValues[0];
if (typeof value === 'string') {
return value;
} else if (value == null) {
return null;
} else {
return value.toString();
}
}
} else {
return null;
}
}, [alert, columnId]);
return <div style={styles}>{getOrEmptyTagFromValue(displayValue)}</div>;
});
CellValue.displayName = 'CellValue';

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import type { DataView } from '@kbn/data-views-plugin/common';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub';
import { TestProviders } from '../../../../common/mock';
import { Table } from './table';
const dataView: DataView = createStubDataView({ spec: {} });
describe('<Table />', () => {
it('should render all components', () => {
const { getByTestId } = render(
<TestProviders>
<Table dataView={dataView} groupingFilters={[]} />
</TestProviders>
);
expect(getByTestId('alertsTableErrorPrompt')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,196 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useMemo } from 'react';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { Filter } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-service';
import { i18n } from '@kbn/i18n';
import { TableId } from '@kbn/securitysolution-data-table';
import { AlertsTable } from '@kbn/response-ops-alerts-table';
import type { AlertsTableProps } from '@kbn/response-ops-alerts-table/types';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import type {
EuiDataGridProps,
EuiDataGridStyle,
EuiDataGridToolBarVisibilityOptions,
} from '@elastic/eui';
import { AdditionalToolbarControls } from './additional_toolbar_controls';
import { getDataViewStateFromIndexFields } from '../../../../common/containers/source/use_data_view';
import { inputsSelectors } from '../../../../common/store';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { combineQueries } from '../../../../common/lib/kuery';
import { useKibana } from '../../../../common/lib/kibana';
import { CellValue } from './render_cell';
import { buildTimeRangeFilter } from '../../alerts_table/helpers';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
const TIMESTAMP_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.timeStamp',
{ defaultMessage: 'Timestamp' }
);
const RELATION_INTEGRATION_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.relatedIntegrationName',
{ defaultMessage: 'Integration' }
);
const SEVERITY_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.severity',
{ defaultMessage: 'Severity' }
);
const RULE_NAME_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.ruleName',
{ defaultMessage: 'Rule' }
);
const TIMESTAMP = '@timestamp';
const RELATED_INTEGRATION = 'kibana.alert.rule.parameters';
const SEVERITY = 'kibana.alert.severity';
const RULE_NAME = 'kibana.alert.rule.name';
const columns: EuiDataGridProps['columns'] = [
{
id: TIMESTAMP,
displayAsText: TIMESTAMP_COLUMN,
},
{
id: RELATED_INTEGRATION,
displayAsText: RELATION_INTEGRATION_COLUMN,
},
{
id: SEVERITY,
displayAsText: SEVERITY_COLUMN,
},
{
id: RULE_NAME,
displayAsText: RULE_NAME_COLUMN,
},
];
const ALERT_TABLE_CONSUMERS: AlertsTableProps['consumers'] = [AlertConsumers.SIEM];
const RULE_TYPE_IDS = [ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID];
const ROW_HEIGHTS_OPTIONS = { defaultHeight: 40 };
const TOOLBAR_VISIBILITY: EuiDataGridToolBarVisibilityOptions = {
showDisplaySelector: false,
showKeyboardShortcuts: false,
showFullScreenSelector: false,
};
const GRID_STYLE: EuiDataGridStyle = { border: 'horizontal' };
export interface TableProps {
/**
* DataView created for the alert summary page
*/
dataView: DataView;
/**
* Groups filters passed from the GroupedAlertsTable component via the renderChildComponent callback
*/
groupingFilters: Filter[];
}
/**
* Renders the table showing all the alerts. This component leverages the ResponseOps AlertsTable in a similar way that the alerts page does.
* The table is used in combination with the GroupedAlertsTable component.
*/
export const Table = memo(({ dataView, groupingFilters }: TableProps) => {
const {
services: {
application,
data,
fieldFormats,
http,
licensing,
notifications,
uiSettings,
settings,
},
} = useKibana();
const services = useMemo(
() => ({
data,
http,
notifications,
fieldFormats,
application,
licensing,
settings,
}),
[application, data, fieldFormats, http, licensing, notifications, settings]
);
const getGlobalFiltersSelector = useMemo(() => inputsSelectors.globalFiltersQuerySelector(), []);
const globalFilters = useDeepEqualSelector(getGlobalFiltersSelector);
const { to, from } = useGlobalTime();
const timeRangeFilter = useMemo(() => buildTimeRangeFilter(from, to), [from, to]);
const filters = useMemo(
() => [
...globalFilters,
...timeRangeFilter,
...groupingFilters.filter((filter) => filter.meta.type !== 'custom'),
],
[globalFilters, groupingFilters, timeRangeFilter]
);
const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]);
const { browserFields } = useMemo(
() => getDataViewStateFromIndexFields('', dataViewSpec.fields),
[dataViewSpec.fields]
);
const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []);
const globalQuery = useDeepEqualSelector(getGlobalQuerySelector);
const query: AlertsTableProps['query'] = useMemo(() => {
const combinedQuery = combineQueries({
config: getEsQueryConfig(uiSettings),
dataProviders: [],
dataViewSpec,
browserFields,
filters,
kqlQuery: globalQuery,
kqlMode: globalQuery.language,
});
if (combinedQuery?.kqlError || !combinedQuery?.filterQuery) {
return { bool: {} };
}
try {
const filter = JSON.parse(combinedQuery?.filterQuery);
return { bool: { filter } };
} catch {
return { bool: {} };
}
}, [browserFields, dataViewSpec, filters, globalQuery, uiSettings]);
const renderAdditionalToolbarControls = useCallback(
() => <AdditionalToolbarControls dataView={dataView} />,
[dataView]
);
return (
<AlertsTable
browserFields={browserFields}
columns={columns}
consumers={ALERT_TABLE_CONSUMERS}
gridStyle={GRID_STYLE}
id={TableId.alertsOnAlertSummaryPage}
query={query}
renderAdditionalToolbarControls={renderAdditionalToolbarControls}
renderCellValue={CellValue}
rowHeightsOptions={ROW_HEIGHTS_OPTIONS}
ruleTypeIds={RULE_TYPE_IDS}
services={services}
toolbarVisibility={TOOLBAR_VISIBILITY}
/>
);
});
Table.displayName = 'Table';

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import type { DataView } from '@kbn/data-views-plugin/common';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub';
import { TestProviders } from '../../../../common/mock';
import { GROUPED_TABLE_TEST_ID, TableSection } from './table_section';
const dataView: DataView = createStubDataView({ spec: {} });
describe('<TableSection />', () => {
it('should render all components', () => {
const { getByTestId } = render(
<TestProviders>
<TableSection dataView={dataView} />
</TestProviders>
);
expect(getByTestId(GROUPED_TABLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId('alertsTableErrorPrompt')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useMemo } from 'react';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { Filter } from '@kbn/es-query';
import { TableId } from '@kbn/securitysolution-data-table';
import { groupStatsRenderer } from './group_stats_renderers';
import { groupingOptions } from './grouping_options';
import { groupTitleRenderers } from './group_title_renderers';
import type { RunTimeMappings } from '../../../../sourcerer/store/model';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { Table } from './table';
import { inputsSelectors } from '../../../../common/store';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { GroupedAlertsTable } from '../../alerts_table/alerts_grouping';
import { groupStatsAggregations } from './group_stats_aggregations';
import { useUserData } from '../../user_info';
export const GROUPED_TABLE_TEST_ID = 'alert-summary-grouped-table';
const runtimeMappings: RunTimeMappings = {};
export interface TableSectionProps {
/**
* DataView created for the alert summary page
*/
dataView: DataView;
}
/**
* Section rendering the table in the alert summary page.
* This component leverages the GroupedAlertsTable and the ResponseOps AlertsTable also used in the alerts page.
*/
export const TableSection = memo(({ dataView }: TableSectionProps) => {
const indexNames = useMemo(() => dataView.getIndexPattern(), [dataView]);
const { to, from } = useGlobalTime();
const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []);
const globalQuery = useDeepEqualSelector(getGlobalQuerySelector);
const getGlobalFiltersSelector = useMemo(() => inputsSelectors.globalFiltersQuerySelector(), []);
const filters = useDeepEqualSelector(getGlobalFiltersSelector);
const [{ hasIndexWrite, hasIndexMaintenance }] = useUserData();
const accordionExtraActionGroupStats = useMemo(
() => ({
aggregations: groupStatsAggregations,
renderer: groupStatsRenderer,
}),
[]
);
const renderChildComponent = useCallback(
(groupingFilters: Filter[]) => <Table dataView={dataView} groupingFilters={groupingFilters} />,
[dataView]
);
return (
<div data-test-subj={GROUPED_TABLE_TEST_ID}>
<GroupedAlertsTable
accordionButtonContent={groupTitleRenderers}
accordionExtraActionGroupStats={accordionExtraActionGroupStats}
defaultGroupingOptions={groupingOptions}
from={from}
globalFilters={filters}
globalQuery={globalQuery}
hasIndexMaintenance={hasIndexMaintenance ?? false}
hasIndexWrite={hasIndexWrite ?? false}
loading={false}
renderChildComponent={renderChildComponent}
runtimeMappings={runtimeMappings}
signalIndexName={indexNames}
tableId={TableId.alertsOnAlertSummaryPage}
to={to}
/>
</div>
);
});
TableSection.displayName = 'TableSection';

View file

@ -23,11 +23,15 @@ import { useIntegrationsLastActivity } from '../../hooks/alert_summary/use_integ
import { ADD_INTEGRATIONS_BUTTON_TEST_ID } from './integrations/integration_section';
import { SEARCH_BAR_TEST_ID } from './search_bar/search_bar_section';
import { KPIS_SECTION } from './kpis/kpis_section';
import { GROUPED_TABLE_TEST_ID } from './table/table_section';
jest.mock('../../../common/components/search_bar', () => ({
// The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables so we can't use SEARCH_BAR_TEST_ID
SiemSearchBar: () => <div data-test-subj={'alert-summary-search-bar'} />,
}));
jest.mock('../alerts_table/alerts_grouping', () => ({
GroupedAlertsTable: () => <div />,
}));
jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/hooks/use_add_integrations_url');
jest.mock('../../hooks/alert_summary/use_integrations_last_activity');
@ -130,6 +134,7 @@ describe('<Wrapper />', () => {
expect(getByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
expect(getByTestId(KPIS_SECTION)).toBeInTheDocument();
expect(getByTestId(GROUPED_TABLE_TEST_ID)).toBeInTheDocument();
});
});
});

View file

@ -20,6 +20,7 @@ import { useKibana } from '../../../common/lib/kibana';
import { KPIsSection } from './kpis/kpis_section';
import { IntegrationSection } from './integrations/integration_section';
import { SearchBarSection } from './search_bar/search_bar_section';
import { TableSection } from './table/table_section';
const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.alertSummary.dataViewError', {
defaultMessage: 'Unable to create data view',
@ -98,6 +99,8 @@ export const Wrapper = memo(({ packages }: WrapperProps) => {
<SearchBarSection dataView={dataView} packages={packages} />
<EuiSpacer />
<KPIsSection dataView={dataView} />
<EuiSpacer />
<TableSection dataView={dataView} />
</div>
)}
</>

View file

@ -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);

View file

@ -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<AlertsGroupingAggregation>) => ({
export const getUsersBadge = (bucket: RawBucket<AlertsGroupingAggregation>): GroupStatsItem => ({
title: i18n.STATS_GROUP_USERS,
badge: {
value: bucket.usersCountAggregation?.value ?? 0,
},
});
export const getHostsBadge = (bucket: RawBucket<AlertsGroupingAggregation>) => ({
export const getHostsBadge = (bucket: RawBucket<AlertsGroupingAggregation>): GroupStatsItem => ({
title: i18n.STATS_GROUP_HOSTS,
badge: {
value: bucket.hostsCountAggregation?.value ?? 0,
},
});
export const getRulesBadge = (bucket: RawBucket<AlertsGroupingAggregation>) => ({
export const getRulesBadge = (bucket: RawBucket<AlertsGroupingAggregation>): GroupStatsItem => ({
title: i18n.STATS_GROUP_RULES,
badge: {
value: bucket.rulesCountAggregation?.value ?? 0,
@ -57,7 +57,6 @@ export const Severity = memo(({ severities }: SingleSeverityProps) => {
<span className="smallDot">
<EuiIcon type="dot" color="#da8b45" />
</span>
<span>
<EuiIcon type="dot" color="#e7664c" />
</span>
@ -137,17 +136,20 @@ export const defaultGroupStatsRenderer = (
selectedGroup: string,
bucket: RawBucket<AlertsGroupingAggregation>
): 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];
};

View file

@ -33,4 +33,7 @@ export interface AlertsGroupingAggregation {
sum_other_doc_count?: number;
buckets?: GenericBuckets[];
};
signalRuleIdSubAggregation?: {
buckets?: GenericBuckets[];
};
}

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import { useFetchIntegrations } from './use_fetch_integrations';
import { useGetIntegrationFromRuleId } from './use_get_integration_from_rule_id';
jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query');
jest.mock('./use_fetch_integrations');
describe('useGetIntegrationFromRuleId', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return undefined integration when no matching rule is found', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({ data: { rules: [] }, isLoading: false });
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [],
isLoading: false,
});
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' }));
expect(result.current.isLoading).toBe(false);
expect(result.current.integration).toBe(undefined);
});
it('should render loading true is rules are loading', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: undefined,
isLoading: true,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [{ name: 'rule_name' }],
isLoading: false,
});
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' }));
expect(result.current.isLoading).toBe(true);
expect(result.current.integration).toBe(undefined);
});
it('should render loading true if packages are loading', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: { rules: [] },
isLoading: false,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [],
isLoading: true,
});
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' }));
expect(result.current.isLoading).toBe(true);
expect(result.current.integration).toBe(undefined);
});
it('should render a matching integration', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: { rules: [{ id: 'rule_id', name: 'rule_name' }] },
isLoading: false,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [{ name: 'rule_name' }],
isLoading: false,
});
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: 'rule_id' }));
expect(result.current.isLoading).toBe(false);
expect(result.current.integration).toEqual({ name: 'rule_name' });
});
});

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { useFetchIntegrations } from './use_fetch_integrations';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import type { RuleResponse } from '../../../../common/api/detection_engine';
export interface UseGetIntegrationFromRuleIdParams {
/**
* Id of the rule. This should be the value from the signal.rule.id field
*/
ruleId: string | string[];
}
export interface UseGetIntegrationFromRuleIdResult {
/**
* List of integrations ready to be consumed by the IntegrationFilterButton component
*/
integration: PackageListItem | undefined;
/**
* True while rules are being fetched
*/
isLoading: boolean;
}
/**
* Hook that fetches rule and packages data. It then uses that data to find if there is a package (integration)
* that matches the rule id value passed via prop (value for the signal.rule.id field).
*
* This hook is used in the GroupedAlertTable's accordion when grouping by signal.rule.id, to render the title as well as statistics.
*/
export const useGetIntegrationFromRuleId = ({
ruleId,
}: UseGetIntegrationFromRuleIdParams): UseGetIntegrationFromRuleIdResult => {
// Fetch all rules. For the AI for SOC effort, there should only be one rule per integration (which means for now 5-6 rules total)
const { data, isLoading: ruleIsLoading } = useFindRulesQuery({});
// Fetch all packages
const { installedPackages, isLoading: integrationIsLoading } = useFetchIntegrations();
// From the ruleId (which should be a value for a signal.rule.id field) we find the rule
// of the same id, which we then use its name to match a package's name.
const integration: PackageListItem | undefined = useMemo(() => {
const signalRuleId = Array.isArray(ruleId) ? ruleId[0] : ruleId;
const rule = (data?.rules || []).find((r: RuleResponse) => r.id === signalRuleId);
if (!rule) {
return undefined;
}
return installedPackages.find((installedPackage) => installedPackage.name === rule.name);
}, [data?.rules, installedPackages, ruleId]);
return useMemo(
() => ({
integration,
isLoading: ruleIsLoading || integrationIsLoading,
}),
[integration, integrationIsLoading, ruleIsLoading]
);
};