Skip to content

Commit

Permalink
[Security Solution][Detection & Response] Open alerts by Rule (#129021)
Browse files Browse the repository at this point in the history
* rule alerts table initial implementation

* move section code to its own directory

* alerts filter by rule uuid

* toggle query added, translations and navigation

* lower case text

* use selector properly

* tests

* all permission checks implemented + remaining tests

* fix test with search bar

* rename hook file

* add useCallback

* fix imports

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
semd and kibanamachine committed Apr 14, 2022
1 parent b5817af commit 2887930
Show file tree
Hide file tree
Showing 12 changed files with 1,148 additions and 88 deletions.
39 changes: 20 additions & 19 deletions x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,19 +169,19 @@ export const useGetUserCasesPermissions = () => {
* Returns a full URL to the provided page path by using
* kibana's `getUrlForApp()`
*/

export type GetAppUrl = (param: {
appId?: string;
deepLinkId?: string;
path?: string;
absolute?: boolean;
}) => string;

export const useAppUrl = () => {
const { getUrlForApp } = useKibana().services.application;

const getAppUrl = useCallback(
({
appId = APP_UI_ID,
...options
}: {
appId?: string;
deepLinkId?: string;
path?: string;
absolute?: boolean;
}) => getUrlForApp(appId, options),
const getAppUrl = useCallback<GetAppUrl>(
({ appId = APP_UI_ID, ...options }) => getUrlForApp(appId, options),
[getUrlForApp]
);
return { getAppUrl };
Expand All @@ -191,18 +191,19 @@ export const useAppUrl = () => {
* Navigate to any app using kibana's `navigateToApp()`
* or by url using `navigateToUrl()`
*/

export type NavigateTo = (
param: {
url?: string;
appId?: string;
} & NavigateToAppOptions
) => void;

export const useNavigateTo = () => {
const { navigateToApp, navigateToUrl } = useKibana().services.application;

const navigateTo = useCallback(
({
url,
appId = APP_UI_ID,
...options
}: {
url?: string;
appId?: string;
} & NavigateToAppOptions) => {
const navigateTo = useCallback<NavigateTo>(
({ url, appId = APP_UI_ID, ...options }) => {
if (url) {
navigateToUrl(url);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export { RuleAlertsTable } from './rule_alerts_table';
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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 { RuleAlertsItem, SeverityRuleAlertsAggsResponse } from './use_rule_alerts_items';

export const from = '2022-04-05T12:00:00.000Z';
export const to = '2022-04-08T12:00:00.000Z';

export const severityRuleAlertsQuery = {
size: 0,
query: {
bool: {
filter: [
{ term: { 'kibana.alert.workflow_status': 'open' } },
{ range: { '@timestamp': { gte: from, lte: to } } },
],
},
},
aggs: {
alertsByRule: {
terms: {
// top 4 rules sorted by severity counters
field: 'kibana.alert.rule.uuid',
size: 4,
order: [{ critical: 'desc' }, { high: 'desc' }, { medium: 'desc' }, { low: 'desc' }],
},
aggs: {
// severity aggregations for sorting
critical: { filter: { term: { 'kibana.alert.severity': 'critical' } } },
high: { filter: { term: { 'kibana.alert.severity': 'high' } } },
medium: { filter: { term: { 'kibana.alert.severity': 'medium' } } },
low: { filter: { term: { 'kibana.alert.severity': 'low' } } },
// get the newest alert to extract timestamp and rule name
lastRuleAlert: {
top_hits: {
size: 1,
sort: {
'@timestamp': 'desc',
},
},
},
},
},
},
};

export const mockSeverityRuleAlertsResponse: { aggregations: SeverityRuleAlertsAggsResponse } = {
aggregations: {
alertsByRule: {
buckets: [
{
key: '79ec0270-b4c5-11ec-970e-8f7c5a7144f7',
doc_count: 54,
lastRuleAlert: {
hits: {
total: {
value: 54,
},
hits: [
{
_source: {
'kibana.alert.rule.name': 'RULE_1',
'@timestamp': '2022-04-05T15:58:35.079Z',
'kibana.alert.severity': 'critical',
},
},
],
},
},
},
{
key: '955c79d0-b403-11ec-b5a7-6dc1ed01bdd7',
doc_count: 112,
lastRuleAlert: {
hits: {
total: {
value: 112,
},
hits: [
{
_source: {
'kibana.alert.rule.name': 'RULE_2',
'@timestamp': '2022-04-05T15:58:47.164Z',
'kibana.alert.severity': 'high',
},
},
],
},
},
},
{
key: '13bc7bc0-b1d6-11ec-a799-67811b37527a',
doc_count: 170,
lastRuleAlert: {
hits: {
total: {
value: 170,
},
hits: [
{
_source: {
'kibana.alert.rule.name': 'RULE_3',
'@timestamp': '2022-04-05T15:56:16.606Z',
'kibana.alert.severity': 'low',
},
},
],
},
},
},
],
},
},
};

export const severityRuleAlertsResponseParsed: RuleAlertsItem[] = [
{
alert_count: 54,
id: '79ec0270-b4c5-11ec-970e-8f7c5a7144f7',
last_alert_at: '2022-04-05T15:58:35.079Z',
name: 'RULE_1',
severity: 'critical',
},
{
alert_count: 112,
id: '955c79d0-b403-11ec-b5a7-6dc1ed01bdd7',
last_alert_at: '2022-04-05T15:58:47.164Z',
name: 'RULE_2',
severity: 'high',
},
{
alert_count: 170,
id: '13bc7bc0-b1d6-11ec-a799-67811b37527a',
last_alert_at: '2022-04-05T15:56:16.606Z',
name: 'RULE_3',
severity: 'low',
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* 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 { TestProviders } from '../../../../common/mock';
import { RuleAlertsTable, RuleAlertsTableProps } from './rule_alerts_table';
import { RuleAlertsItem, UseRuleAlertsItems } from './use_rule_alerts_items';
import moment from 'moment';
import { SecurityPageName } from '../../../../../common/constants';

const mockGetAppUrl = jest.fn();
jest.mock('../../../../common/lib/kibana/hooks', () => {
const original = jest.requireActual('../../../../common/lib/kibana/hooks');
return {
...original,
useNavigation: () => ({
getAppUrl: mockGetAppUrl,
}),
};
});

type UseRuleAlertsItemsReturn = ReturnType<UseRuleAlertsItems>;
const defaultUseRuleAlertsItemsReturn: UseRuleAlertsItemsReturn = {
items: [],
isLoading: false,
updatedAt: Date.now(),
};
const mockUseRuleAlertsItems = jest.fn(() => defaultUseRuleAlertsItemsReturn);
const mockUseRuleAlertsItemsReturn = (param: Partial<UseRuleAlertsItemsReturn>) => {
mockUseRuleAlertsItems.mockReturnValueOnce({ ...defaultUseRuleAlertsItemsReturn, ...param });
};
jest.mock('./use_rule_alerts_items', () => ({
useRuleAlertsItems: () => mockUseRuleAlertsItems(),
}));

const defaultProps: RuleAlertsTableProps = {
signalIndexName: '',
};
const items: RuleAlertsItem[] = [
{
id: 'ruleId',
name: 'ruleName',
last_alert_at: moment().subtract(1, 'day').format(),
alert_count: 10,
severity: 'high',
},
];

describe('RuleAlertsTable', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should render empty table', () => {
const result = render(
<TestProviders>
<RuleAlertsTable {...defaultProps} />
</TestProviders>
);

expect(result.getByTestId('severityRuleAlertsPanel')).toBeInTheDocument();
expect(result.getByText('No alerts to display')).toBeInTheDocument();
expect(result.getByTestId('severityRuleAlertsButton')).toBeInTheDocument();
});

it('should render a loading table', () => {
mockUseRuleAlertsItemsReturn({ isLoading: true });
const result = render(
<TestProviders>
<RuleAlertsTable {...defaultProps} />
</TestProviders>
);

expect(result.getByText('Updating...')).toBeInTheDocument();
expect(result.getByTestId('severityRuleAlertsButton')).toBeInTheDocument();
expect(result.getByTestId('severityRuleAlertsTable')).toHaveClass('euiBasicTable-loading');
});

it('should render the updated at subtitle', () => {
mockUseRuleAlertsItemsReturn({ isLoading: false });
const result = render(
<TestProviders>
<RuleAlertsTable {...defaultProps} />
</TestProviders>
);

expect(result.getByText('Updated now')).toBeInTheDocument();
});

it('should render the table columns', () => {
mockUseRuleAlertsItemsReturn({ items });
const result = render(
<TestProviders>
<RuleAlertsTable {...defaultProps} />
</TestProviders>
);

const columnHeaders = result.getAllByRole('columnheader');
expect(columnHeaders.at(0)).toHaveTextContent('Rule name');
expect(columnHeaders.at(1)).toHaveTextContent('Last alert');
expect(columnHeaders.at(2)).toHaveTextContent('Alert count');
expect(columnHeaders.at(3)).toHaveTextContent('Severity');
});

it('should render the table items', () => {
mockUseRuleAlertsItemsReturn({ items });
const result = render(
<TestProviders>
<RuleAlertsTable {...defaultProps} />
</TestProviders>
);

expect(result.getByTestId('severityRuleAlertsTable-name')).toHaveTextContent('ruleName');
expect(result.getByTestId('severityRuleAlertsTable-lastAlertAt')).toHaveTextContent(
'yesterday'
);
expect(result.getByTestId('severityRuleAlertsTable-alertCount')).toHaveTextContent('10');
expect(result.getByTestId('severityRuleAlertsTable-severity')).toHaveTextContent('High');
});

it('should generate the table items links', () => {
const linkUrl = '/fake/link';
mockGetAppUrl.mockReturnValue(linkUrl);
mockUseRuleAlertsItemsReturn({ items });

const result = render(
<TestProviders>
<RuleAlertsTable {...defaultProps} />
</TestProviders>
);

expect(mockGetAppUrl).toBeCalledWith({
deepLinkId: SecurityPageName.rules,
path: `id/${items[0].id}`,
});

expect(result.getByTestId('severityRuleAlertsTable-name')).toHaveAttribute('href', linkUrl);
});
});
Loading

0 comments on commit 2887930

Please sign in to comment.