Skip to content

Commit 27bc009

Browse files
[AI4DSOC] Alert summary KQL bar (#215586)
## Summary This PR adds the SiemSearchBar to the alert summary page. The search bar is pretty basic: it hides the query menu and the filter buttons to the left of the query input. Instead, the PR builds a new filter button. That button lists all the sources available. Sources are basically equivalent to integrations, or their corresponding rules. It is a friendly UI name to abstract the concept or a rule. In the AI for SOC effort, each integration is bundled with a single rule. This means that deselecting a source from the Source filter button is equivalent to adding a filter to the search bar to exclude all alerts with the `kibana.alert.rule.name` property having the value of that integration. ### Example: There are following 2 integrations installed: ```typescript [ { id: 'splunk', name: 'splunk', status: installationStatuses.Installed, title: 'Splunk', version: '', }, { id: 'google_secops', name: 'google_secops', status: installationStatuses.Installed, title: 'Google SecOps', version: '', }, ] ``` This means that - in theory - there are the following 2 rules installed and running: ```typescript [ { related_integrations: [{ package: 'splunk' }], name: 'Splunk Rule', }, { related_integrations: [{ package: 'google_secops' }], name: 'Google SecOps Rule', }, ] ``` In this case, the `Sources` button would show 2 entries, as follow: ```typescript [ { checked: 'on', key: 'Splunk Rule', label: 'Splunk', }, { checked: 'on', key: 'Google SecOps Rule', label: 'Splunk', }, ] ``` By default, the `checked` property should be set to `on`. It would be `off` if a filter for the corresponding `label` existed. https://github.com/user-attachments/assets/059815d2-9181-4bf1-bd78-e0e5bfa7439d https://github.com/user-attachments/assets/126606c7-b4e0-4d0b-82c1-b531c6490de3 ## 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' }, ] ``` And this to generate data: `yarn test:generate:serverless-dev` Use one of these Serverless users: - `platform_engineer` - `endpoint_operations_analyst` - `endpoint_policy_manager` - `admin` - `system_indices_superuser` ### Notes You'll need to either have some AI for SOC integrations installed, or more easily you can: - change the `alert_summary.tsx` line `38` from `if (installedPackages.length === 0) {` to `if (installedPackages.length > 0) {` to force the wrapper component to render - update `42` of the same `alert_summary.tsx` file from `return <Wrapper packages={installedPackages} />;` to `return <Wrapper packages={availablePackages} />;` to be able to see some packages - comment out line the if condition line `66` of `use_integrations.ts` file to make sure that values are added even if there is no `matchingRule` - replace `const ruleName = changedOption.key;` with `const ruleName = changedOption.label;` on line `78` of the `integrations_filter_button.tsx` file ### 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 ### Links Ticket elastic/security-team#11956 Mocks https://www.figma.com/design/DYs7j4GQdAhg7aWTLI4R69/AI4DSOC?node-id=3284-70999&m=dev --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 4f0aa2b commit 27bc009

11 files changed

Lines changed: 925 additions & 7 deletions

File tree

x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { set } from '@kbn/safer-lodash-set/fp';
99
import { getOr } from 'lodash/fp';
10-
import React, { memo, useEffect, useCallback, useMemo } from 'react';
10+
import React, { memo, useCallback, useEffect, useMemo } from 'react';
1111
import type { ConnectedProps } from 'react-redux';
1212
import { connect, useDispatch } from 'react-redux';
1313
import type { Dispatch } from 'redux';
@@ -16,14 +16,14 @@ import deepEqual from 'fast-deep-equal';
1616

1717
import type { Filter, Query, TimeRange } from '@kbn/es-query';
1818
import type { FilterManager, SavedQuery } from '@kbn/data-plugin/public';
19+
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
1920
import { DataView } from '@kbn/data-views-plugin/public';
2021

2122
import type { OnTimeChangeProps } from '@elastic/eui';
22-
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
2323
import { inputsActions } from '../../store/inputs';
2424
import type { InputsRange } from '../../store/inputs/model';
2525
import type { InputsModelId } from '../../store/inputs/constants';
26-
import type { State, inputsModel } from '../../store';
26+
import type { inputsModel, State } from '../../store';
2727
import { formatDate } from '../super_date_picker';
2828
import {
2929
endSelector,
@@ -51,6 +51,10 @@ interface SiemSearchBarProps {
5151
dataTestSubj?: string;
5252
hideFilterBar?: boolean;
5353
hideQueryInput?: boolean;
54+
/**
55+
* Allows to hide the query menu button displayed to the left of the query input.
56+
*/
57+
hideQueryMenu?: boolean;
5458
}
5559

5660
export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
@@ -60,6 +64,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
6064
fromStr,
6165
hideFilterBar = false,
6266
hideQueryInput = false,
67+
hideQueryMenu = false,
6368
id,
6469
isLoading = false,
6570
pollForSignalIndex,
@@ -337,6 +342,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
337342
showFilterBar={!hideFilterBar}
338343
showDatePicker={true}
339344
showQueryInput={!hideQueryInput}
345+
showQueryMenu={!hideQueryMenu}
340346
allowSavingQueries
341347
dataTestSubj={dataTestSubj}
342348
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { act, render } from '@testing-library/react';
10+
import { useKibana } from '../../../../common/lib/kibana';
11+
import {
12+
INTEGRATION_BUTTON_TEST_ID,
13+
IntegrationFilterButton,
14+
INTEGRATIONS_LIST_TEST_ID,
15+
} from './integrations_filter_button';
16+
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
17+
18+
jest.mock('../../../../common/lib/kibana');
19+
20+
const integrations: EuiSelectableOption[] = [
21+
{
22+
'data-test-subj': 'first',
23+
checked: 'on',
24+
key: 'firstKey',
25+
label: 'firstLabel',
26+
},
27+
{
28+
'data-test-subj': 'second',
29+
key: 'secondKey',
30+
label: 'secondLabel',
31+
},
32+
];
33+
34+
describe('<IntegrationFilterButton />', () => {
35+
it('should render the component', async () => {
36+
(useKibana as jest.Mock).mockReturnValue({
37+
services: { data: { query: { filterManager: jest.fn() } } },
38+
});
39+
40+
await act(async () => {
41+
const { getByTestId } = render(<IntegrationFilterButton integrations={integrations} />);
42+
43+
const button = getByTestId(INTEGRATION_BUTTON_TEST_ID);
44+
expect(button).toBeInTheDocument();
45+
button.click();
46+
47+
await new Promise(process.nextTick);
48+
49+
expect(getByTestId(INTEGRATIONS_LIST_TEST_ID)).toBeInTheDocument();
50+
51+
expect(getByTestId('first')).toHaveTextContent('firstLabel');
52+
expect(getByTestId('second')).toHaveTextContent('secondLabel');
53+
});
54+
});
55+
56+
it('should add a negated filter to filterManager', async () => {
57+
const getFilters = jest.fn().mockReturnValue([]);
58+
const setFilters = jest.fn();
59+
(useKibana as jest.Mock).mockReturnValue({
60+
services: { data: { query: { filterManager: { getFilters, setFilters } } } },
61+
});
62+
63+
await act(async () => {
64+
const { getByTestId } = render(<IntegrationFilterButton integrations={integrations} />);
65+
66+
getByTestId(INTEGRATION_BUTTON_TEST_ID).click();
67+
68+
await new Promise(process.nextTick);
69+
70+
getByTestId('first').click();
71+
expect(setFilters).toHaveBeenCalledWith([
72+
{
73+
meta: {
74+
alias: null,
75+
disabled: false,
76+
index: undefined,
77+
key: 'kibana.alert.rule.name',
78+
negate: true,
79+
params: { query: 'firstKey' },
80+
type: 'phrase',
81+
},
82+
query: { match_phrase: { 'kibana.alert.rule.name': 'firstKey' } },
83+
},
84+
]);
85+
});
86+
});
87+
88+
it('should remove the negated filter from filterManager', async () => {
89+
const getFilters = jest.fn().mockReturnValue([
90+
{
91+
meta: {
92+
alias: null,
93+
disabled: false,
94+
index: undefined,
95+
key: 'kibana.alert.rule.name',
96+
negate: true,
97+
params: { query: 'secondKey' },
98+
type: 'phrase',
99+
},
100+
query: { match_phrase: { 'kibana.alert.rule.name': 'secondKey' } },
101+
},
102+
]);
103+
const setFilters = jest.fn();
104+
(useKibana as jest.Mock).mockReturnValue({
105+
services: { data: { query: { filterManager: { getFilters, setFilters } } } },
106+
});
107+
108+
await act(async () => {
109+
const { getByTestId } = render(<IntegrationFilterButton integrations={integrations} />);
110+
111+
getByTestId(INTEGRATION_BUTTON_TEST_ID).click();
112+
113+
await new Promise(process.nextTick);
114+
115+
// creates a new filter that
116+
getByTestId('second').click();
117+
expect(setFilters).toHaveBeenCalledWith([]);
118+
});
119+
});
120+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { memo, useCallback, useState } from 'react';
9+
import { css } from '@emotion/react';
10+
import {
11+
EuiFilterButton,
12+
EuiFilterGroup,
13+
EuiPopover,
14+
EuiSelectable,
15+
useEuiTheme,
16+
useGeneratedHtmlId,
17+
} from '@elastic/eui';
18+
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
19+
import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable';
20+
import type { Filter } from '@kbn/es-query';
21+
import { i18n } from '@kbn/i18n';
22+
import { updateFiltersArray } from '../../../utils/filter';
23+
import { useKibana } from '../../../../common/lib/kibana';
24+
25+
export const INTEGRATION_BUTTON_TEST_ID = 'alert-summary-integration-button';
26+
export const INTEGRATIONS_LIST_TEST_ID = 'alert-summary-integrations-list';
27+
28+
const INTEGRATIONS_BUTTON = i18n.translate(
29+
'xpack.securitySolution.alertSummary.integrations.buttonLabel',
30+
{
31+
defaultMessage: 'Integrations',
32+
}
33+
);
34+
35+
export const FILTER_KEY = 'kibana.alert.rule.name';
36+
37+
export interface IntegrationFilterButtonProps {
38+
/**
39+
* List of integrations the user can select or deselect
40+
*/
41+
integrations: EuiSelectableOption[];
42+
}
43+
44+
/**
45+
* Filter button displayed next to the KQL bar at the top of the alert summary page.
46+
* For the AI for SOC effort, each integration has one rule associated with.
47+
* This means that deselecting an integration is equivalent to filtering out by the rule for that integration.
48+
* The EuiFilterButton works as follow:
49+
* - if an integration is selected, this means that no filters live in filterManager
50+
* - if an integration is deselected, this means that we have a negated filter for that rule in filterManager
51+
*/
52+
export const IntegrationFilterButton = memo(({ integrations }: IntegrationFilterButtonProps) => {
53+
const { euiTheme } = useEuiTheme();
54+
55+
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
56+
const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []);
57+
58+
const {
59+
data: {
60+
query: { filterManager },
61+
},
62+
} = useKibana().services;
63+
64+
const filterGroupPopoverId = useGeneratedHtmlId({
65+
prefix: 'filterGroupPopover',
66+
});
67+
68+
const [items, setItems] = useState<EuiSelectableOption[]>(integrations);
69+
70+
const onChange = useCallback(
71+
(
72+
options: EuiSelectableOption[],
73+
_: EuiSelectableOnChangeEvent,
74+
changedOption: EuiSelectableOption
75+
) => {
76+
setItems(options);
77+
78+
const ruleName = changedOption.key;
79+
if (ruleName) {
80+
const existingFilters = filterManager.getFilters();
81+
const newFilters: Filter[] = updateFiltersArray(
82+
existingFilters,
83+
FILTER_KEY,
84+
ruleName,
85+
changedOption.checked === 'on'
86+
);
87+
filterManager.setFilters(newFilters);
88+
}
89+
},
90+
[filterManager]
91+
);
92+
93+
const button = (
94+
<EuiFilterButton
95+
badgeColor="accent"
96+
css={css`
97+
background-color: ${euiTheme.colors.backgroundBasePrimary};
98+
`}
99+
data-test-subj={INTEGRATION_BUTTON_TEST_ID}
100+
hasActiveFilters={!!items.find((item) => item.checked === 'on')}
101+
iconType="arrowDown"
102+
isSelected={isPopoverOpen}
103+
numActiveFilters={items.filter((item) => item.checked === 'on').length}
104+
numFilters={items.filter((item) => item.checked !== 'off').length}
105+
onClick={togglePopover}
106+
>
107+
{INTEGRATIONS_BUTTON}
108+
</EuiFilterButton>
109+
);
110+
111+
return (
112+
<EuiFilterGroup>
113+
<EuiPopover
114+
button={button}
115+
closePopover={togglePopover}
116+
id={filterGroupPopoverId}
117+
isOpen={isPopoverOpen}
118+
panelPaddingSize="none"
119+
>
120+
<EuiSelectable
121+
css={css`
122+
min-width: 200px;
123+
`}
124+
data-test-subj={INTEGRATIONS_LIST_TEST_ID}
125+
options={items}
126+
onChange={onChange}
127+
>
128+
{(list) => list}
129+
</EuiSelectable>
130+
</EuiPopover>
131+
</EuiFilterGroup>
132+
);
133+
});
134+
135+
IntegrationFilterButton.displayName = 'IntegrationFilterButton';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render } from '@testing-library/react';
10+
import type { PackageListItem } from '@kbn/fleet-plugin/common';
11+
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
12+
import {
13+
INTEGRATION_BUTTON_LOADING_TEST_ID,
14+
SEARCH_BAR_TEST_ID,
15+
SearchBarSection,
16+
} from './search_bar_section';
17+
import type { DataView } from '@kbn/data-views-plugin/common';
18+
import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub';
19+
import { INTEGRATION_BUTTON_TEST_ID } from './integrations_filter_button';
20+
import { useKibana } from '../../../../common/lib/kibana';
21+
import { useIntegrations } from '../../../hooks/alert_summary/use_integrations';
22+
23+
jest.mock('../../../../common/components/search_bar', () => ({
24+
// 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
25+
SiemSearchBar: () => <div data-test-subj={'alert-summary-search-bar'} />,
26+
}));
27+
jest.mock('../../../../common/lib/kibana');
28+
jest.mock('../../../hooks/alert_summary/use_integrations');
29+
30+
const dataView: DataView = createStubDataView({ spec: {} });
31+
const packages: PackageListItem[] = [
32+
{
33+
id: 'splunk',
34+
name: 'splunk',
35+
status: installationStatuses.Installed,
36+
title: 'Splunk',
37+
version: '',
38+
},
39+
];
40+
41+
describe('<SearchBarSection />', () => {
42+
it('should render all components', () => {
43+
(useIntegrations as jest.Mock).mockReturnValue({
44+
isLoading: false,
45+
integrations: [],
46+
});
47+
(useKibana as jest.Mock).mockReturnValue({
48+
services: { data: { query: { filterManager: jest.fn() } } },
49+
});
50+
51+
const { getByTestId, queryByTestId } = render(
52+
<SearchBarSection dataView={dataView} packages={packages} />
53+
);
54+
55+
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
56+
expect(queryByTestId(INTEGRATION_BUTTON_LOADING_TEST_ID)).not.toBeInTheDocument();
57+
expect(getByTestId(INTEGRATION_BUTTON_TEST_ID)).toBeInTheDocument();
58+
});
59+
60+
it('should render a loading skeleton for the integration button while fetching rules', () => {
61+
(useIntegrations as jest.Mock).mockReturnValue({
62+
isLoading: true,
63+
integrations: [],
64+
});
65+
66+
const { getByTestId, queryByTestId } = render(
67+
<SearchBarSection dataView={dataView} packages={packages} />
68+
);
69+
70+
expect(getByTestId(INTEGRATION_BUTTON_LOADING_TEST_ID)).toBeInTheDocument();
71+
expect(queryByTestId(INTEGRATION_BUTTON_TEST_ID)).not.toBeInTheDocument();
72+
});
73+
});

0 commit comments

Comments
 (0)