[Cloud Posture] Use ES PIT in findings page (#132503)

This commit is contained in:
Ari Aviran 2022-05-31 17:50:00 +03:00 committed by GitHub
parent ec7f3d703d
commit cbca99d860
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 570 additions and 119 deletions

View file

@ -7,10 +7,10 @@
export const INFO_ROUTE_PATH = '/internal/cloud_security_posture/setup_status';
export const STATS_ROUTE_PATH = '/internal/cloud_security_posture/stats';
export const FINDINGS_ROUTE_PATH = '/internal/cloud_security_posture/findings';
export const BENCHMARKS_ROUTE_PATH = '/internal/cloud_security_posture/benchmarks';
export const UPDATE_RULES_CONFIG_ROUTE_PATH =
'/internal/cloud_security_posture/update_rules_config';
export const ES_PIT_ROUTE_PATH = '/internal/cloud_security_posture/es_pit';
export const CLOUD_SECURITY_POSTURE_PACKAGE_NAME = 'cloud_security_posture';

View file

@ -18,7 +18,9 @@ import { UnknownRoute } from '../components/unknown_route';
import type { CspClientPluginStartDeps } from '../types';
import { pageToComponentMapping } from './constants';
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
});
export interface CspAppDeps {
core: CoreStart;

View file

@ -7,6 +7,7 @@
import { useQuery } from 'react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-plugin/common';
import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../common/constants';
import { CspClientPluginStartDeps } from '../../types';
@ -18,8 +19,14 @@ export const useLatestFindingsDataView = () => {
data: { dataViews },
} = useKibana<CspClientPluginStartDeps>().services;
// TODO: use `dataViews.get(ID)`
const findDataView = async () => (await dataViews.find(CSP_LATEST_FINDINGS_DATA_VIEW))?.[0];
const findDataView = async (): Promise<DataView> => {
const dataView = (await dataViews.find(CSP_LATEST_FINDINGS_DATA_VIEW))?.[0];
if (!dataView) {
throw new Error('Findings data view not found');
}
return useQuery(['latest_findings_dataview'], findDataView);
return dataView;
};
return useQuery(['latest_findings_data_view'], findDataView);
};

View file

@ -5,17 +5,28 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, useEuiTheme } from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
export const CspLoadingState: React.FunctionComponent<{ ['data-test-subj']?: string }> = ({
children,
...rest
}) => (
<EuiFlexGroup direction="column" alignItems="center" data-test-subj={rest['data-test-subj']}>
<EuiFlexItem>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
);
}) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup
css={css`
padding: ${euiTheme.size.l};
`}
direction="column"
alignItems="center"
data-test-subj={rest['data-test-subj']}
>
<EuiFlexItem>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -132,6 +132,22 @@ describe('<CspPageTemplate />', () => {
);
});
it('renders default loading text when query is idle', () => {
const query = createReactQueryResponse({
status: 'idle',
}) as unknown as UseQueryResult;
const children = chance.sentence();
renderCspPageTemplate({ children, query });
expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
expect(screen.queryByText(children)).not.toBeInTheDocument();
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
packageNotInstalledUniqueTexts.forEach((text) =>
expect(screen.queryByText(text)).not.toBeInTheDocument()
);
});
it('renders default error texts when query isError', () => {
const error = chance.sentence();
const message = chance.sentence();

View file

@ -188,7 +188,9 @@ export const CspPageTemplate = <TData, TError>({
};
const render = () => {
if (query?.isLoading || cisKubernetesPackageInfo.isLoading) return loadingRender();
if (query?.isLoading || query?.isIdle || cisKubernetesPackageInfo.isLoading) {
return loadingRender();
}
if (query?.isError) return errorRender(query.error);
if (query?.isSuccess) return children;

View file

@ -5,5 +5,6 @@
* 2.0.
*/
export const RULE_PASSED = `passed`;
export const RULE_FAILED = `failed`;
export const FINDINGS_PIT_KEEP_ALIVE = '2m';
// Set to half of the PIT keep alive to make sure we keep the PIT window open as long as the components are mounted
export const FINDINGS_REFETCH_INTERVAL_MS = 1000 * 60; // One minute

View file

@ -0,0 +1,20 @@
/*
* 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 { createContext, type MutableRefObject } from 'react';
import type { UseQueryResult } from 'react-query';
interface FindingsEsPitContextValue {
setPitId(newPitId: string): void;
pitIdRef: MutableRefObject<string>;
pitQuery: UseQueryResult<string>;
}
// Default value should never be used, it can not be instantiated statically. Always wrap in a provider with a value
export const FindingsEsPitContext = createContext<FindingsEsPitContextValue>(
{} as FindingsEsPitContextValue
);

View file

@ -0,0 +1,44 @@
/*
* 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 { useCallback, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { CSP_LATEST_FINDINGS_DATA_VIEW, ES_PIT_ROUTE_PATH } from '../../../../common/constants';
import { useKibana } from '../../../common/hooks/use_kibana';
import { FINDINGS_PIT_KEEP_ALIVE } from '../constants';
export const useFindingsEsPit = (queryKey: string) => {
// Using a reference for the PIT ID to avoid re-rendering when it changes
const pitIdRef = useRef<string>();
// Using this state as an internal control to ensure we run the query to open the PIT once and only once
const [isPitIdSet, setPitIdSet] = useState(false);
const setPitId = useCallback(
(newPitId: string) => {
pitIdRef.current = newPitId;
setPitIdSet(true);
},
[pitIdRef, setPitIdSet]
);
const { http } = useKibana().services;
const pitQuery = useQuery(
['findingsPitQuery', queryKey],
() =>
http.post<string>(ES_PIT_ROUTE_PATH, {
query: { index_name: CSP_LATEST_FINDINGS_DATA_VIEW, keep_alive: FINDINGS_PIT_KEEP_ALIVE },
}),
{
enabled: !isPitIdSet,
onSuccess: (pitId) => {
setPitId(pitId);
},
cacheTime: 0,
}
);
return { pitIdRef, setPitId, pitQuery };
};

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import React from 'react';
import type { UseQueryResult } from 'react-query';
import { Redirect, Switch, Route, useLocation } from 'react-router-dom';
import { useFindingsEsPit } from './es_pit/use_findings_es_pit';
import { FindingsEsPitContext } from './es_pit/findings_es_pit_context';
import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view';
import { allNavigationItems, findingsNavigation } from '../../common/navigation/constants';
import { CspPageTemplate } from '../../components/csp_page_template';
@ -15,37 +18,51 @@ import { LatestFindingsContainer } from './latest_findings/latest_findings_conta
export const Findings = () => {
const location = useLocation();
const dataViewQuery = useLatestFindingsDataView();
// TODO: Consider splitting the PIT window so that each "group by" view has its own PIT
const { pitQuery, pitIdRef, setPitId } = useFindingsEsPit('findings');
if (!dataViewQuery.data) return <CspPageTemplate paddingSize="none" query={dataViewQuery} />;
let queryForPageTemplate: UseQueryResult = dataViewQuery;
if (pitQuery.isError || pitQuery.isLoading || pitQuery.isIdle) {
queryForPageTemplate = pitQuery;
}
return (
<CspPageTemplate paddingSize="none" query={dataViewQuery}>
<Switch>
<Route
exact
path={allNavigationItems.findings.path}
component={() => (
<Redirect
to={{
pathname: findingsNavigation.findings_default.path,
search: location.search,
}}
/>
)}
/>
<Route
path={findingsNavigation.findings_default.path}
render={() => <LatestFindingsContainer dataView={dataViewQuery.data} />}
/>
<Route
path={findingsNavigation.findings_by_resource.path}
render={() => <FindingsByResourceContainer dataView={dataViewQuery.data} />}
/>
<Route
path={'*'}
component={() => <Redirect to={findingsNavigation.findings_default.path} />}
/>
</Switch>
<CspPageTemplate paddingSize="none" query={queryForPageTemplate}>
<FindingsEsPitContext.Provider
value={{
pitQuery,
// Asserting the ref as a string value since at this point the query was necessarily successful
pitIdRef: pitIdRef as React.MutableRefObject<string>,
setPitId,
}}
>
<Switch>
<Route
exact
path={allNavigationItems.findings.path}
component={() => (
<Redirect
to={{
pathname: findingsNavigation.findings_default.path,
search: location.search,
}}
/>
)}
/>
<Route
path={findingsNavigation.findings_default.path}
render={() => <LatestFindingsContainer dataView={dataViewQuery.data!} />}
/>
<Route
path={findingsNavigation.findings_by_resource.path}
render={() => <FindingsByResourceContainer dataView={dataViewQuery.data!} />}
/>
<Route
path={'*'}
component={() => <Redirect to={findingsNavigation.findings_default.path} />}
/>
</Switch>
</FindingsEsPitContext.Provider>
</CspPageTemplate>
);
};

View file

@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseQueryResult } from 'react-query';
import { createReactQueryResponse } from '../../../test/fixtures/react_query';
import React from 'react';
import { render } from '@testing-library/react';
import { LatestFindingsContainer, getDefaultQuery } from './latest_findings_container';
@ -19,6 +21,7 @@ import { RisonObject } from 'rison-node';
import { buildEsQuery } from '@kbn/es-query';
import { getFindingsCountAggQuery } from '../use_findings_count';
import { getPaginationQuery } from '../utils';
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
jest.mock('../../../common/api/use_latest_findings_data_view');
jest.mock('../../../common/api/use_cis_kubernetes_integration');
@ -47,6 +50,13 @@ describe('<LatestFindingsContainer />', () => {
search: encodeQuery(query as unknown as RisonObject),
});
const setPitId = jest.fn();
const pitIdRef = { current: '' };
const pitQuery = createReactQueryResponse({
status: 'success',
data: '',
}) as UseQueryResult<string>;
render(
<TestProvider
deps={{
@ -54,13 +64,15 @@ describe('<LatestFindingsContainer />', () => {
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
}}
>
<LatestFindingsContainer dataView={dataView} />
<FindingsEsPitContext.Provider value={{ setPitId, pitIdRef, pitQuery }}>
<LatestFindingsContainer dataView={dataView} />
</FindingsEsPitContext.Provider>
</TestProvider>
);
const baseQuery = {
index: dataView.title,
query: buildEsQuery(dataView, query.query, query.filters),
pitId: pitIdRef.current,
};
expect(dataMock.search.search).toHaveBeenNthCalledWith(1, {

View file

@ -6,9 +6,9 @@
*/
import React, { useMemo } from 'react';
import { EuiSpacer } from '@elastic/eui';
import type { DataView } from '@kbn/data-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import { number } from 'io-ts';
import type { FindingsBaseProps } from '../types';
import { FindingsTable } from './latest_findings_table';
import { FindingsSearchBar } from '../layout/findings_search_bar';
import * as TEST_SUBJECTS from '../test_subjects';
@ -32,7 +32,7 @@ export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQue
pageSize: 10,
});
export const LatestFindingsContainer = ({ dataView }: { dataView: DataView }) => {
export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
useCspBreadcrumbs([findingsNavigation.findings_default]);
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useContext } from 'react';
import { useQuery } from 'react-query';
import { number } from 'io-ts';
import { lastValueFrom } from 'rxjs';
@ -11,11 +12,14 @@ import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import type { CoreStart } from '@kbn/core/public';
import type { Criteria, Pagination } from '@elastic/eui';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
import { extractErrorMessage } from '../../../../common/utils/helpers';
import * as TEXT from '../translations';
import type { CspFindingsQueryData } from '../types';
import type { CspFinding, FindingsQueryResult } from '../types';
import { useKibana } from '../../../common/hooks/use_kibana';
import type { FindingsBaseEsQuery } from '../types';
import { FINDINGS_REFETCH_INTERVAL_MS } from '../constants';
interface UseFindingsOptions extends FindingsBaseEsQuery {
from: NonNullable<estypes.SearchRequest['from']>;
@ -31,12 +35,7 @@ export interface FindingsGroupByNoneQuery {
sort: Sort;
}
interface CspFindingsData {
page: CspFinding[];
total: number;
}
export type CspFindingsResult = FindingsQueryResult<CspFindingsData | undefined, unknown>;
export type CspFindingsResult = FindingsQueryResult<CspFindingsQueryData | undefined, unknown>;
const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set([
'@timestamp',
@ -57,35 +56,55 @@ export const showErrorToast = (
else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED));
};
export const getFindingsQuery = ({ index, query, size, from, sort }: UseFindingsOptions) => ({
index,
export const getFindingsQuery = ({
query,
size,
from,
sort,
pitId,
}: UseFindingsOptions & { pitId: string }) => ({
query,
size,
from,
sort: [{ [getSortKey(sort.field)]: sort.direction }],
pit: { id: pitId },
ignore_unavailable: false,
});
export const useLatestFindings = ({ index, query, sort, from, size }: UseFindingsOptions) => {
export const useLatestFindings = ({ query, sort, from, size }: UseFindingsOptions) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
return useQuery(
['csp_findings', { index, query, sort, from, size }],
return useQuery<
IEsSearchResponse<CspFinding>,
unknown,
CspFindingsQueryData & { newPitId: string }
>(
['csp_findings', { query, sort, from, size, pitId }],
() =>
lastValueFrom<IEsSearchResponse<CspFinding>>(
data.search.search({
params: getFindingsQuery({ index, query, sort, from, size }),
params: getFindingsQuery({ query, sort, from, size, pitId }),
})
),
{
keepPreviousData: true,
select: ({ rawResponse: { hits } }) => ({
select: ({ rawResponse: { hits, pit_id: newPitId } }) => ({
page: hits.hits.map((hit) => hit._source!),
total: number.is(hits.total) ? hits.total : 0,
newPitId: newPitId!,
}),
onError: (err) => showErrorToast(toasts, err),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},
// Refetching on an interval to ensure the PIT window stays open
refetchInterval: FINDINGS_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: true,
}
);
};

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import React from 'react';
import type { DataView } from '@kbn/data-plugin/common';
import { Route, Switch } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';
import { FindingsSearchBar } from '../layout/findings_search_bar';
import * as TEST_SUBJECTS from '../test_subjects';
import { useUrlQuery } from '../../../common/hooks/use_url_query';
import type { FindingsBaseURLQuery } from '../types';
import type { FindingsBaseProps, FindingsBaseURLQuery } from '../types';
import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource';
import { FindingsByResourceTable } from './findings_by_resource_table';
import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../utils';
@ -28,7 +27,7 @@ const getDefaultQuery = (): FindingsBaseURLQuery & FindingsByResourceQuery => ({
pageSize: 10,
});
export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => (
export const FindingsByResourceContainer = ({ dataView }: FindingsBaseProps) => (
<Switch>
<Route
exact
@ -42,7 +41,7 @@ export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }
</Switch>
);
const LatestFindingsByResource = ({ dataView }: { dataView: DataView }) => {
const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => {
useCspBreadcrumbs([findingsNavigation.findings_by_resource]);
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
const findingsGroupByResource = useFindingsByResource({

View file

@ -6,7 +6,6 @@
*/
import React from 'react';
import { EuiSpacer, EuiButtonEmpty } from '@elastic/eui';
import type { DataView } from '@kbn/data-plugin/common';
import { Link, useParams } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';
import { useEuiTheme } from '@elastic/eui';
@ -17,7 +16,7 @@ import { useCspBreadcrumbs } from '../../../../common/navigation/use_csp_breadcr
import { findingsNavigation } from '../../../../common/navigation/constants';
import { ResourceFindingsQuery, useResourceFindings } from './use_resource_findings';
import { useUrlQuery } from '../../../../common/hooks/use_url_query';
import type { FindingsBaseURLQuery } from '../../types';
import type { FindingsBaseProps, FindingsBaseURLQuery } from '../../types';
import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../../utils';
import { ResourceFindingsTable } from './resource_findings_table';
import { FindingsSearchBar } from '../../layout/findings_search_bar';
@ -40,7 +39,7 @@ const BackToResourcesButton = () => (
</Link>
);
export const ResourceFindings = ({ dataView }: { dataView: DataView }) => {
export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
useCspBreadcrumbs([findingsNavigation.findings_default]);
const { euiTheme } = useEuiTheme();
const params = useParams<{ resourceId: string }>();

View file

@ -9,8 +9,12 @@ import { lastValueFrom } from 'rxjs';
import { IEsSearchResponse } from '@kbn/data-plugin/common';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { Pagination } from '@elastic/eui';
import { useContext } from 'react';
import { FindingsEsPitContext } from '../../es_pit/findings_es_pit_context';
import { FINDINGS_REFETCH_INTERVAL_MS } from '../../constants';
import { useKibana } from '../../../../common/hooks/use_kibana';
import { showErrorToast } from '../../latest_findings/use_latest_findings';
import type { CspFindingsQueryData } from '../../types';
import type { CspFinding, FindingsBaseEsQuery, FindingsQueryResult } from '../../types';
interface UseResourceFindingsOptions extends FindingsBaseEsQuery {
@ -24,19 +28,15 @@ export interface ResourceFindingsQuery {
pageSize: Pagination['pageSize'];
}
export type ResourceFindingsResult = FindingsQueryResult<
ReturnType<typeof useResourceFindings>['data'] | undefined,
unknown
>;
export type ResourceFindingsResult = FindingsQueryResult<CspFindingsQueryData | undefined, unknown>;
const getResourceFindingsQuery = ({
index,
query,
resourceId,
from,
size,
}: UseResourceFindingsOptions): estypes.SearchRequest => ({
index,
pitId,
}: UseResourceFindingsOptions & { pitId: string }): estypes.SearchRequest => ({
from,
size,
body: {
@ -47,11 +47,12 @@ const getResourceFindingsQuery = ({
filter: [...(query?.bool?.filter || []), { term: { 'resource_id.keyword': resourceId } }],
},
},
pit: { id: pitId },
},
ignore_unavailable: false,
});
export const useResourceFindings = ({
index,
query,
resourceId,
from,
@ -62,21 +63,35 @@ export const useResourceFindings = ({
notifications: { toasts },
} = useKibana().services;
return useQuery(
['csp_resource_findings', { index, query, resourceId, from, size }],
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
return useQuery<
IEsSearchResponse<CspFinding>,
unknown,
CspFindingsQueryData & { newPitId: string }
>(
['csp_resource_findings', { query, resourceId, from, size, pitId }],
() =>
lastValueFrom<IEsSearchResponse<CspFinding>>(
data.search.search({
params: getResourceFindingsQuery({ index, query, resourceId, from, size }),
params: getResourceFindingsQuery({ query, resourceId, from, size, pitId }),
})
),
{
keepPreviousData: true,
select: ({ rawResponse: { hits } }) => ({
select: ({ rawResponse: { hits, pit_id: newPitId } }) => ({
page: hits.hits.map((hit) => hit._source!),
total: hits.total as number,
newPitId: newPitId!,
}),
onError: (err) => showErrorToast(toasts, err),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},
// Refetching on an interval to ensure the PIT window stays open
refetchInterval: FINDINGS_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: true,
}
);
};

View file

@ -4,11 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useContext } from 'react';
import { useQuery } from 'react-query';
import { lastValueFrom } from 'rxjs';
import { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { Pagination } from '@elastic/eui';
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
import { FINDINGS_REFETCH_INTERVAL_MS } from '../constants';
import { useKibana } from '../../../common/hooks/use_kibana';
import { showErrorToast } from '../latest_findings/use_latest_findings';
import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types';
@ -31,8 +34,26 @@ type FindingsAggResponse = IKibanaSearchResponse<
estypes.SearchResponse<{}, FindingsByResourceAggs>
>;
interface FindingsByResourcePage {
failed_findings: {
count: number;
normalized: number;
total_findings: number;
};
resource_id: string;
resource_name: string;
resource_subtype: string;
cluster_id: string;
cis_sections: string[];
}
interface UseFindingsByResourceData {
page: FindingsByResourcePage[];
total: number;
}
export type CspFindingsByResourceResult = FindingsQueryResult<
ReturnType<typeof useFindingsByResource>['data'],
UseFindingsByResourceData | undefined,
unknown
>;
@ -50,12 +71,11 @@ interface FindingsAggBucket extends estypes.AggregationsStringRareTermsBucketKey
}
export const getFindingsByResourceAggQuery = ({
index,
query,
from,
size,
}: UseResourceFindingsOptions): estypes.SearchRequest => ({
index,
pitId,
}: UseResourceFindingsOptions & { pitId: string }): estypes.SearchRequest => ({
body: {
query,
size: 0,
@ -95,23 +115,28 @@ export const getFindingsByResourceAggQuery = ({
},
},
},
pit: { id: pitId },
},
ignore_unavailable: false,
});
export const useFindingsByResource = ({ index, query, from, size }: UseResourceFindingsOptions) => {
export const useFindingsByResource = ({ query, from, size }: UseResourceFindingsOptions) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['csp_findings_resource', { index, query, size, from }],
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
return useQuery<UseFindingsByResourceData & { newPitId: string }>(
['csp_findings_resource', { query, size, from, pitId }],
() =>
lastValueFrom(
data.search.search<FindingsAggRequest, FindingsAggResponse>({
params: getFindingsByResourceAggQuery({ index, query, from, size }),
params: getFindingsByResourceAggQuery({ query, from, size, pitId }),
})
).then(({ rawResponse: { aggregations } }) => {
).then(({ rawResponse: { aggregations, pit_id: newPitId } }) => {
if (!aggregations) throw new Error('expected aggregations to be defined');
if (!Array.isArray(aggregations.resources.buckets))
@ -120,16 +145,23 @@ export const useFindingsByResource = ({ index, query, from, size }: UseResourceF
return {
page: aggregations.resources.buckets.map(createFindingsByResource),
total: aggregations.resource_total.value,
newPitId: newPitId!,
};
}),
{
keepPreviousData: true,
onError: (err) => showErrorToast(toasts, err),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},
// Refetching on an interval to ensure the PIT window stays open
refetchInterval: FINDINGS_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: true,
}
);
};
const createFindingsByResource = (resource: FindingsAggBucket) => {
const createFindingsByResource = (resource: FindingsAggBucket): FindingsByResourcePage => {
if (
!Array.isArray(resource.cis_sections.buckets) ||
!Array.isArray(resource.name.buckets) ||

View file

@ -4,8 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DataView } from '@kbn/data-views-plugin/common';
import type { BoolQuery, Filter, Query } from '@kbn/es-query';
import { UseQueryResult } from 'react-query';
import type { UseQueryResult } from 'react-query';
export type FindingsGroupByKind = 'default' | 'resource';
@ -14,8 +15,11 @@ export interface FindingsBaseURLQuery {
filters: Filter[];
}
export interface FindingsBaseProps {
dataView: DataView;
}
export interface FindingsBaseEsQuery {
index: string;
query?: {
bool: BoolQuery;
};
@ -98,3 +102,8 @@ interface CspFindingAgent {
name: string;
type: string;
}
export interface CspFindingsQueryData {
page: CspFinding[];
total: number;
}

View file

@ -8,9 +8,11 @@ import { useQuery } from 'react-query';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { lastValueFrom } from 'rxjs';
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/public';
import { useContext } from 'react';
import { useKibana } from '../../common/hooks/use_kibana';
import { showErrorToast } from './latest_findings/use_latest_findings';
import type { FindingsBaseEsQuery } from './types';
import { FindingsEsPitContext } from './es_pit/findings_es_pit_context';
type FindingsAggRequest = IKibanaSearchRequest<estypes.SearchRequest>;
type FindingsAggResponse = IKibanaSearchResponse<estypes.SearchResponse<{}, FindingsAggs>>;
@ -23,40 +25,57 @@ interface FindingsAggs extends estypes.AggregationsMultiBucketAggregateBase {
};
}
export const getFindingsCountAggQuery = ({ index, query }: FindingsBaseEsQuery) => ({
index,
interface UseFindingsCounterData {
passed: number;
failed: number;
}
export const getFindingsCountAggQuery = ({
query,
pitId,
}: FindingsBaseEsQuery & { pitId: string }) => ({
size: 0,
track_total_hits: true,
body: {
query,
aggs: { count: { terms: { field: 'result.evaluation.keyword' } } },
pit: { id: pitId },
},
ignore_unavailable: false,
});
export const useFindingsCounter = ({ index, query }: FindingsBaseEsQuery) => {
export const useFindingsCounter = ({ query }: FindingsBaseEsQuery) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['csp_findings_counts', { index, query }],
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
return useQuery<FindingsAggResponse, unknown, UseFindingsCounterData & { newPitId: string }>(
['csp_findings_counts', { query, pitId }],
() =>
lastValueFrom(
data.search.search<FindingsAggRequest, FindingsAggResponse>({
params: getFindingsCountAggQuery({ index, query }),
params: getFindingsCountAggQuery({ query, pitId }),
})
),
{
keepPreviousData: true,
onError: (err) => showErrorToast(toasts, err),
select: (response) =>
Object.fromEntries(
select: (response) => ({
...(Object.fromEntries(
response.rawResponse.aggregations!.count.buckets.map((bucket) => [
bucket.key,
bucket.doc_count,
])!
) as Record<'passed' | 'failed', number>,
) as { passed: number; failed: number }),
newPitId: response.rawResponse.pit_id!,
}),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},
}
);
};

View file

@ -6,16 +6,15 @@
*/
import { buildEsQuery } from '@kbn/es-query';
import type { DataView } from '@kbn/data-plugin/common';
import { EuiBasicTableProps, Pagination } from '@elastic/eui';
import { FindingsBaseProps } from './types';
import type { FindingsBaseEsQuery, FindingsBaseURLQuery } from './types';
export const getBaseQuery = ({
dataView,
query,
filters,
}: FindingsBaseURLQuery & { dataView: DataView }): FindingsBaseEsQuery => ({
index: dataView.title,
}: FindingsBaseURLQuery & FindingsBaseProps): FindingsBaseEsQuery => ({
// TODO: this will throw for malformed query
// page will display an error boundary with the JS error
// will be accounted for before releasing the feature

View file

@ -28,20 +28,17 @@ export type RulesQueryResult = ReturnType<typeof useFindCspRules>;
export const useFindCspRules = ({ search, page, perPage, filter }: RulesQuery) => {
const { savedObjects } = useKibana().services;
return useQuery(
[cspRuleAssetSavedObjectType, { search, page, perPage }],
() =>
savedObjects.client.find<CspRuleSchema>({
type: cspRuleAssetSavedObjectType,
search,
searchFields: ['name'],
page: 1,
// NOTE: 'name.raw' is a field mapping we defined on 'name'
sortField: 'name.raw',
perPage,
filter,
}),
{ refetchOnWindowFocus: false }
return useQuery([cspRuleAssetSavedObjectType, { search, page, perPage }], () =>
savedObjects.client.find<CspRuleSchema>({
type: cspRuleAssetSavedObjectType,
search,
searchFields: ['name'],
page: 1,
// NOTE: 'name.raw' is a field mapping we defined on 'name'
sortField: 'name.raw',
perPage,
filter,
})
);
};

View file

@ -33,5 +33,16 @@ export const createReactQueryResponse = <TData = unknown, TError = unknown>({
return { status, data: undefined, isSuccess: false, isLoading: true, isError: false };
}
if (status === 'idle') {
return {
status,
data: undefined,
isSuccess: false,
isLoading: false,
isError: false,
isIdle: true,
};
}
return { status };
};

View file

@ -0,0 +1,168 @@
/*
* 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 { Chance } from 'chance';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from '@kbn/core/server/elasticsearch/client/mocks';
import type { ElasticsearchClient } from '@kbn/core/server';
import { httpServerMock, httpServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { DEFAULT_PIT_KEEP_ALIVE, defineEsPitRoute, esPitInputSchema } from './es_pit';
import { CspAppService } from '../../lib/csp_app_services';
import { CspAppContext } from '../../plugin';
describe('ES Point in time API endpoint', () => {
const chance = new Chance();
let mockEsClient: jest.Mocked<ElasticsearchClient>;
beforeEach(() => {
jest.clearAllMocks();
});
it('validate the API route path', () => {
const router = httpServiceMock.createRouter();
const cspContext: CspAppContext = {
logger: loggingSystemMock.createLogger(),
service: new CspAppService(),
};
defineEsPitRoute(router, cspContext);
const [config] = router.post.mock.calls[0];
expect(config.path).toEqual('/internal/cloud_security_posture/es_pit');
});
it('should accept to a user with fleet.all privilege', async () => {
const router = httpServiceMock.createRouter();
const cspContext: CspAppContext = {
logger: loggingSystemMock.createLogger(),
service: new CspAppService(),
};
defineEsPitRoute(router, cspContext);
const mockContext = {
fleet: { authz: { fleet: { all: true } } },
};
const mockResponse = httpServerMock.createResponseFactory();
const mockRequest = httpServerMock.createKibanaRequest();
const [context, req, res] = [mockContext, mockRequest, mockResponse];
const [_, handler] = router.post.mock.calls[0];
await handler(context, req, res);
expect(res.forbidden).toHaveBeenCalledTimes(0);
});
it('should reject to a user without fleet.all privilege', async () => {
const router = httpServiceMock.createRouter();
const cspContext: CspAppContext = {
logger: loggingSystemMock.createLogger(),
service: new CspAppService(),
};
defineEsPitRoute(router, cspContext);
const mockContext = {
fleet: { authz: { fleet: { all: false } } },
};
const mockResponse = httpServerMock.createResponseFactory();
const mockRequest = httpServerMock.createKibanaRequest();
const [context, req, res] = [mockContext, mockRequest, mockResponse];
const [_, handler] = router.post.mock.calls[0];
await handler(context, req, res);
expect(res.forbidden).toHaveBeenCalledTimes(1);
});
it('should return the newly created PIT ID from ES', async () => {
const router = httpServiceMock.createRouter();
const cspContext: CspAppContext = {
logger: loggingSystemMock.createLogger(),
service: new CspAppService(),
};
defineEsPitRoute(router, cspContext);
const pitId = chance.string();
mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
mockEsClient.openPointInTime.mockImplementation(() => Promise.resolve({ id: pitId }));
const mockContext = {
fleet: { authz: { fleet: { all: true } } },
core: { elasticsearch: { client: { asCurrentUser: mockEsClient } } },
};
const indexName = chance.string();
const keepAlive = chance.string();
const mockResponse = httpServerMock.createResponseFactory();
const mockRequest = httpServerMock.createKibanaRequest({
query: { index_name: indexName, keep_alive: keepAlive },
});
const [context, req, res] = [mockContext, mockRequest, mockResponse];
const [_, handler] = router.post.mock.calls[0];
await handler(context, req, res);
expect(mockEsClient.openPointInTime).toHaveBeenCalledTimes(1);
expect(mockEsClient.openPointInTime).toHaveBeenLastCalledWith({
index: indexName,
keep_alive: keepAlive,
});
expect(res.ok).toHaveBeenCalledTimes(1);
expect(res.ok).toHaveBeenLastCalledWith({ body: pitId });
});
describe('test input schema', () => {
it('passes keep alive and index name parameters', () => {
const indexName = chance.string();
const keepAlive = chance.string();
const validatedQuery = esPitInputSchema.validate({
index_name: indexName,
keep_alive: keepAlive,
});
expect(validatedQuery).toMatchObject({
index_name: indexName,
keep_alive: keepAlive,
});
});
it('populates default keep alive parameter value', () => {
const indexName = chance.string();
const validatedQuery = esPitInputSchema.validate({ index_name: indexName });
expect(validatedQuery).toMatchObject({
index_name: indexName,
keep_alive: DEFAULT_PIT_KEEP_ALIVE,
});
});
it('throws when index name parameter is not passed', () => {
expect(() => {
esPitInputSchema.validate({});
}).toThrow();
});
it('throws when index name parameter is not a string', () => {
const indexName = chance.integer();
expect(() => {
esPitInputSchema.validate({ index_name: indexName });
}).toThrow();
});
it('throws when keep alive parameter is not a string', () => {
const indexName = chance.string();
const keepAlive = chance.integer();
expect(() => {
esPitInputSchema.validate({ index_name: indexName, keep_alive: keepAlive });
}).toThrow();
});
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { transformError } from '@kbn/securitysolution-es-utils';
import { ES_PIT_ROUTE_PATH } from '../../../common/constants';
import type { CspAppContext } from '../../plugin';
import type { CspRouter } from '../../types';
export const DEFAULT_PIT_KEEP_ALIVE = '1m';
export const esPitInputSchema = schema.object({
index_name: schema.string(),
keep_alive: schema.string({ defaultValue: DEFAULT_PIT_KEEP_ALIVE }),
});
export const defineEsPitRoute = (router: CspRouter, cspContext: CspAppContext): void =>
router.post(
{
path: ES_PIT_ROUTE_PATH,
validate: { query: esPitInputSchema },
},
async (context, request, response) => {
if (!(await context.fleet).authz.fleet.all) {
return response.forbidden();
}
try {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asCurrentUser;
const { id } = await esClient.openPointInTime({
index: request.query.index_name,
keep_alive: request.query.keep_alive,
});
return response.ok({ body: id });
} catch (err) {
const error = transformError(err);
cspContext.logger.error(`Failed to open Elasticsearch point in time: ${error}`);
return response.customError({
body: { message: error.message },
statusCode: error.statusCode,
});
}
}
);

View file

@ -9,6 +9,7 @@ import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compli
import { defineGetBenchmarksRoute } from './benchmarks/benchmarks';
import { defineUpdateRulesConfigRoute } from './configuration/update_rules_configuration';
import { defineGetCspSetupStatusRoute } from './setup_status/setup_status';
import { defineEsPitRoute } from './es_pit/es_pit';
import { CspAppContext } from '../plugin';
import { CspRouter } from '../types';
@ -17,4 +18,5 @@ export function defineRoutes(router: CspRouter, cspContext: CspAppContext) {
defineGetBenchmarksRoute(router, cspContext);
defineUpdateRulesConfigRoute(router, cspContext);
defineGetCspSetupStatusRoute(router, cspContext);
defineEsPitRoute(router, cspContext);
}