Skip to content

Commit

Permalink
[Security Solution] Add Search Bar to Security D&R and EA Dashboards (#…
Browse files Browse the repository at this point in the history
…156832)

More details on the issue:
elastic/security-team#6504
## TODO

- [x] Unit tests
- [ ] Cypress tests (follow-up PR)

## Summary

* Add global search bar and filter to EA and D&R pages.
* Create `useGlobalFilterQuery` hook to simplify adding global search
bar filters to a page
* Filter alert column in risk table by time range

![May-05-2023
15-12-34](https://user-images.githubusercontent.com/1490444/236467186-f6e6c435-447b-41f4-a6b6-8bd4a3deb498.gif)
![May-05-2023
15-13-42](https://user-images.githubusercontent.com/1490444/236467191-df8cc05a-3c0c-4f37-929f-4d7723e23055.gif)

<img width="1402" alt="Screenshot 2023-05-08 at 13 27 54"
src="https://user-images.githubusercontent.com/1490444/236812677-e6021d99-4be1-44d7-8449-26f9330d8b78.png">

### Tooltips explaining that some pages are not affected by the KQL
search bar (Last minute addition)

<img width="747" alt="Screenshot 2023-05-08 at 17 57 32"
src="https://user-images.githubusercontent.com/1490444/236871990-3ebd60fa-ea45-4f98-a8d9-5813ac2b10de.png">
<img width="1512" alt="Screenshot 2023-05-08 at 17 57 37"
src="https://user-images.githubusercontent.com/1490444/236871998-94969be6-b194-4d19-b83e-12f9b96eda1b.png">
<img width="1512" alt="Screenshot 2023-05-08 at 17 57 51"
src="https://user-images.githubusercontent.com/1490444/236872002-5255f799-f30b-44f1-bd90-8f19037b6915.png">

### Glossary
* **EA:** Entity Analytics
* **D&R:** Detection & Response

### Checklist

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

(cherry picked from commit 7fd9ca6)
  • Loading branch information
machadoum committed May 9, 2023
1 parent 07208d7 commit 1fd4c9b
Show file tree
Hide file tree
Showing 29 changed files with 512 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface RiskScoreRequestOptions extends IEsSearchRequest {
defaultIndex: string[];
riskScoreEntity: RiskScoreEntity;
timerange?: TimerangeInput;
alertsTimerange?: TimerangeInput;
includeAlertsCount?: boolean;
onlyLatest?: boolean;
pagination?: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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-hooks';
import { TestProviders } from '../mock';
import { useGlobalFilterQuery } from './use_global_filter_query';
import type { Filter, Query } from '@kbn/es-query';

const DEFAULT_QUERY: Query = { query: '', language: 'kuery' };

const mockGlobalFiltersQuerySelector = jest.fn();
const mockGlobalQuerySelector = jest.fn();
const mockUseInvalidFilterQuery = jest.fn();

jest.mock('../store', () => {
const original = jest.requireActual('../store');
return {
...original,
inputsSelectors: {
...original.inputsSelectors,
globalFiltersQuerySelector: () => mockGlobalFiltersQuerySelector,
globalQuerySelector: () => mockGlobalQuerySelector,
},
};
});

jest.mock('./use_invalid_filter_query', () => ({
useInvalidFilterQuery: (...args: unknown[]) => mockUseInvalidFilterQuery(...args),
}));

describe('useGlobalFilterQuery', () => {
beforeEach(() => {
mockGlobalFiltersQuerySelector.mockReturnValue([]);
mockGlobalQuerySelector.mockReturnValue(DEFAULT_QUERY);
});

it('returns filterQuery', () => {
const { result } = renderHook(() => useGlobalFilterQuery(), { wrapper: TestProviders });

expect(result.current.filterQuery).toEqual({
bool: { must: [], filter: [], should: [], must_not: [] },
});
});

it('filters by KQL search', () => {
mockGlobalQuerySelector.mockReturnValue({ query: 'test: 123', language: 'kuery' });
const { result } = renderHook(() => useGlobalFilterQuery(), { wrapper: TestProviders });

expect(result.current.filterQuery).toEqual({
bool: {
must: [],
filter: [
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: '123',
},
},
],
},
},
],
should: [],
must_not: [],
},
});
});

it('filters by global filters', () => {
const query = {
match_phrase: {
test: '1234',
},
};
const globalFilter: Filter[] = [
{
meta: {
disabled: false,
},
query,
},
];
mockGlobalFiltersQuerySelector.mockReturnValue(globalFilter);
const { result } = renderHook(() => useGlobalFilterQuery(), { wrapper: TestProviders });

expect(result.current.filterQuery).toEqual({
bool: {
must: [],
filter: [query],
should: [],
must_not: [],
},
});
});

it('filters by extra filter', () => {
const query = {
match_phrase: {
test: '12345',
},
};
const extraFilter: Filter = {
meta: {
disabled: false,
},
query,
};

const { result } = renderHook(() => useGlobalFilterQuery({ extraFilter }), {
wrapper: TestProviders,
});

expect(result.current.filterQuery).toEqual({
bool: {
must: [],
filter: [query],
should: [],
must_not: [],
},
});
});

it('displays the KQL error when query is invalid', () => {
mockGlobalQuerySelector.mockReturnValue({ query: ': :', language: 'kuery' });
const { result } = renderHook(() => useGlobalFilterQuery(), { wrapper: TestProviders });

expect(result.current.filterQuery).toEqual(undefined);
expect(mockUseInvalidFilterQuery).toHaveBeenLastCalledWith(
expect.objectContaining({
kqlError: expect.anything(),
})
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* 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 { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { DataViewBase, EsQueryConfig, Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { useGlobalTime } from '../containers/use_global_time';
import { useKibana } from '../lib/kibana';
import { inputsSelectors } from '../store';
import { useDeepEqualSelector } from './use_selector';
import { useInvalidFilterQuery } from './use_invalid_filter_query';
import type { ESBoolQuery } from '../../../common/typed_json';

interface GlobalFilterQueryProps {
extraFilter?: Filter;
dataView?: DataViewBase;
}

/**
* It builds a global filterQuery from KQL search bar and global filters.
* It also validates the query and shows a warning if it's invalid.
*/
export const useGlobalFilterQuery = ({ extraFilter, dataView }: GlobalFilterQueryProps = {}) => {
const { from, to } = useGlobalTime();
const getGlobalFiltersQuerySelector = useMemo(
() => inputsSelectors.globalFiltersQuerySelector(),
[]
);
const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []);
const query = useDeepEqualSelector(getGlobalQuerySelector);
const globalFilters = useDeepEqualSelector(getGlobalFiltersQuerySelector);
const { uiSettings } = useKibana().services;

const filters = useMemo(() => {
const enabledFilters = globalFilters.filter((f) => f.meta.disabled === false);

return extraFilter ? [...enabledFilters, extraFilter] : enabledFilters;
}, [extraFilter, globalFilters]);

const { filterQuery, kqlError } = useMemo(
() => buildQueryOrError(query, filters, getEsQueryConfig(uiSettings), dataView),
[dataView, query, filters, uiSettings]
);

const filterQueryStringified = useMemo(
() => (filterQuery ? JSON.stringify(filterQuery) : undefined),
[filterQuery]
);

useInvalidFilterQuery({
id: 'GlobalFilterQuery', // It prevents displaying multiple times the same error popup
filterQuery: filterQueryStringified,
kqlError,
query,
startDate: from,
endDate: to,
});

return { filterQuery };
};

const buildQueryOrError = (
query: Query,
filters: Filter[],
config: EsQueryConfig,
dataView?: DataViewBase
): { filterQuery?: ESBoolQuery; kqlError?: Error } => {
try {
return { filterQuery: buildEsQuery(dataView, [query], filters, config) };
} catch (kqlError) {
return { kqlError };
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ export const USER_WARNING_TITLE = i18n.translate(
export const HOST_WARNING_BODY = i18n.translate(
'xpack.securitySolution.riskScore.hostsDashboardWarningPanelBody',
{
defaultMessage: `We haven't detected any host risk score data from the hosts in your environment. The data might need an hour to be generated after enabling the module.`,
defaultMessage: `We haven’t found any host risk score data. Check if you have any global filters in the global KQL search bar. If you have just enabled the host risk module, the risk engine might need an hour to generate host risk score data and display in this panel.`,
}
);

export const USER_WARNING_BODY = i18n.translate(
'xpack.securitySolution.riskScore.usersDashboardWarningPanelBody',
{
defaultMessage: `We haven't detected any user risk score data from the users in your environment. The data might need an hour to be generated after enabling the module.`,
defaultMessage: `We haven’t found any user risk score data. Check if you have any global filters in the global KQL search bar. If you have just enabled the user risk module, the risk engine might need an hour to generate user risk score data and display in this panel.`,
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export const useRiskScore = <T extends RiskScoreEntity.host | RiskScoreEntity.us
: undefined,
sort,
timerange: onlyLatest ? undefined : requestTimerange,
alertsTimerange: includeAlertsCount ? requestTimerange : undefined,
}
: null,
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '../../../../../common/search_strategy';
import * as i18n from './translations';
import { isIndexNotFoundError } from '../../../../common/utils/exceptions';
import type { ESTermQuery } from '../../../../../common/typed_json';
import type { ESQuery } from '../../../../../common/typed_json';
import type { SeverityCount } from '../../../components/risk_score/severity/types';
import { useSpaceId } from '../../../../common/hooks/use_space_id';
import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities';
Expand All @@ -37,7 +37,7 @@ interface RiskScoreKpi {
}

interface UseRiskScoreKpiProps {
filterQuery?: string | ESTermQuery;
filterQuery?: string | ESQuery;
skip?: boolean;
riskEntity: RiskScoreEntity;
timerange?: { to: string; from: string };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { HeaderSection } from '../../../../common/components/header_section';
import {
CASES,
CASES_BY_STATUS_SECTION_TITLE,
CASES_BY_STATUS_SECTION_TOOLTIP,
STATUS_CLOSED,
STATUS_IN_PROGRESS,
STATUS_OPEN,
Expand Down Expand Up @@ -143,6 +144,7 @@ const CasesByStatusComponent: React.FC = () => {
toggleQuery={setToggleStatus}
subtitle={<LastUpdatedAt updatedAt={updatedAt} isUpdating={isLoading} />}
showInspectButton={false}
tooltip={CASES_BY_STATUS_SECTION_TOOLTIP}
>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const CasesTable = React.memo(() => {
toggleQuery={setToggleStatus}
subtitle={<LastUpdatedAt updatedAt={updatedAt} isUpdating={isLoading} />}
showInspectButton={false}
tooltip={i18n.CASES_TABLE_SECTION_TOOLTIP}
/>

{toggleStatus && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ jest.mock('../../../../common/hooks/use_navigate_to_alerts_page_with_filters', (
};
});

jest.mock('../../../../common/hooks/use_global_filter_query', () => {
return {
useGlobalFilterQuery: () => ({}),
};
});

type UseHostAlertsItemsReturn = ReturnType<UseHostAlertsItems>;
const defaultUseHostAlertsItemsReturn: UseHostAlertsItemsReturn = {
items: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
SecurityCellActions,
SecurityCellActionsTrigger,
} from '../../../../common/components/cell_actions';
import { useGlobalFilterQuery } from '../../../../common/hooks/use_global_filter_query';

interface HostAlertsTableProps {
signalIndexName: string | null;
Expand All @@ -51,6 +52,7 @@ const DETECTION_RESPONSE_HOST_SEVERITY_QUERY_ID = 'vulnerableHostsBySeverityQuer

export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableProps) => {
const openAlertsPageWithFilters = useNavigateToAlertsPageWithFilters();
const { filterQuery } = useGlobalFilterQuery();

const openHostInAlerts = useCallback(
({ hostName, severity }: { hostName: string; severity?: string }) =>
Expand Down Expand Up @@ -81,6 +83,7 @@ export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableP
skip: !toggleStatus,
queryId: DETECTION_RESPONSE_HOST_SEVERITY_QUERY_ID,
signalIndexName,
filterQuery,
});

const columns = useMemo(() => getTableColumns(openHostInAlerts), [openHostInAlerts]);
Expand Down
Loading

0 comments on commit 1fd4c9b

Please sign in to comment.