mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
c2de4d02cf
commit
579dbae6a1
23 changed files with 1733 additions and 34 deletions
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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];
|
||||
}
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
},
|
||||
];
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
|
|
|
@ -33,4 +33,7 @@ export interface AlertsGroupingAggregation {
|
|||
sum_other_doc_count?: number;
|
||||
buckets?: GenericBuckets[];
|
||||
};
|
||||
signalRuleIdSubAggregation?: {
|
||||
buckets?: GenericBuckets[];
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
|
@ -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]
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue