mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Cloud Posture] add findings group by resource table (#130455)
This commit is contained in:
parent
8c991cfaef
commit
7df6a0dad5
12 changed files with 406 additions and 131 deletions
|
@ -9,9 +9,9 @@ import { useHistory } from 'react-router-dom';
|
|||
import { Query } from '@kbn/es-query';
|
||||
import { allNavigationItems } from '../navigation/constants';
|
||||
import { encodeQuery } from '../navigation/query_utils';
|
||||
import { CspFindingsRequest } from '../../pages/findings/use_findings';
|
||||
import { FindingsBaseURLQuery } from '../../pages/findings/types';
|
||||
|
||||
const getFindingsQuery = (queryValue: Query['query']): Pick<CspFindingsRequest, 'query'> => {
|
||||
const getFindingsQuery = (queryValue: Query['query']): Pick<FindingsBaseURLQuery, 'query'> => {
|
||||
const query =
|
||||
typeof queryValue === 'string'
|
||||
? queryValue
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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, screen, within } from '@testing-library/react';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import { FindingsByResourceTable, formatNumber, getResourceId } from './findings_by_resource_table';
|
||||
import * as TEXT from './translations';
|
||||
import type { PropsOf } from '@elastic/eui';
|
||||
import Chance from 'chance';
|
||||
import numeral from '@elastic/numeral';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
const getFakeFindingsByResource = () => ({
|
||||
resource_id: chance.guid(),
|
||||
cluster_id: chance.guid(),
|
||||
cis_section: chance.word(),
|
||||
failed_findings: {
|
||||
total: chance.integer(),
|
||||
normalized: chance.integer({ min: 0, max: 1 }),
|
||||
},
|
||||
});
|
||||
|
||||
type TableProps = PropsOf<typeof FindingsByResourceTable>;
|
||||
|
||||
describe('<FindingsByResourceTable />', () => {
|
||||
it('renders the zero state when status success and data has a length of zero ', async () => {
|
||||
const props: TableProps = {
|
||||
loading: false,
|
||||
data: { page: [] },
|
||||
error: null,
|
||||
};
|
||||
|
||||
render(<FindingsByResourceTable {...props} />);
|
||||
|
||||
expect(screen.getByText(TEXT.NO_FINDINGS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the table with provided items', () => {
|
||||
const data = Array.from({ length: 10 }, getFakeFindingsByResource);
|
||||
|
||||
const props: TableProps = {
|
||||
loading: false,
|
||||
data: { page: data },
|
||||
error: null,
|
||||
};
|
||||
|
||||
render(<FindingsByResourceTable {...props} />);
|
||||
|
||||
data.forEach((item, i) => {
|
||||
const row = screen.getByTestId(
|
||||
TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(item))
|
||||
);
|
||||
expect(row).toBeInTheDocument();
|
||||
expect(within(row).getByText(item.resource_id)).toBeInTheDocument();
|
||||
expect(within(row).getByText(item.cluster_id)).toBeInTheDocument();
|
||||
expect(within(row).getByText(item.cis_section)).toBeInTheDocument();
|
||||
expect(within(row).getByText(formatNumber(item.failed_findings.total))).toBeInTheDocument();
|
||||
expect(
|
||||
within(row).getByText(new RegExp(numeral(item.failed_findings.normalized).format('0%')))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiTableFieldDataColumnType,
|
||||
EuiEmptyPrompt,
|
||||
EuiBasicTable,
|
||||
EuiTextColor,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { extractErrorMessage } from '../../../common/utils/helpers';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import * as TEXT from './translations';
|
||||
import type { CspFindingsByResourceResult } from './use_findings_by_resource';
|
||||
|
||||
export const formatNumber = (value: number) =>
|
||||
value < 1000 ? value : numeral(value).format('0.0a');
|
||||
|
||||
type FindingsGroupByResourceProps = CspFindingsByResourceResult;
|
||||
type CspFindingsByResource = NonNullable<CspFindingsByResourceResult['data']>['page'][number];
|
||||
|
||||
export const getResourceId = (resource: CspFindingsByResource) =>
|
||||
[resource.resource_id, resource.cluster_id, resource.cis_section].join('/');
|
||||
|
||||
const FindingsByResourceTableComponent = ({
|
||||
error,
|
||||
data,
|
||||
loading,
|
||||
}: FindingsGroupByResourceProps) => {
|
||||
const getRowProps = (row: CspFindingsByResource) => ({
|
||||
'data-test-subj': TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(row)),
|
||||
});
|
||||
|
||||
if (!loading && !data?.page.length)
|
||||
return <EuiEmptyPrompt iconType="logoKibana" title={<h2>{TEXT.NO_FINDINGS}</h2>} />;
|
||||
|
||||
return (
|
||||
<EuiBasicTable
|
||||
loading={loading}
|
||||
error={error ? extractErrorMessage(error) : undefined}
|
||||
items={data?.page || []}
|
||||
columns={columns}
|
||||
rowProps={getRowProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: Array<EuiTableFieldDataColumnType<CspFindingsByResource>> = [
|
||||
{
|
||||
field: 'resource_id',
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.csp.findings.groupByResourceTable.resourceIdColumnLabel"
|
||||
defaultMessage="Resource ID"
|
||||
/>
|
||||
),
|
||||
render: (resourceId: CspFindingsByResource['resource_id']) => <EuiLink>{resourceId}</EuiLink>,
|
||||
},
|
||||
{
|
||||
field: 'cis_section',
|
||||
truncateText: true,
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.csp.findings.groupByResourceTable.cisSectionColumnLabel"
|
||||
defaultMessage="CIS Section"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'cluster_id',
|
||||
truncateText: true,
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.csp.findings.groupByResourceTable.clusterIdColumnLabel"
|
||||
defaultMessage="Cluster ID"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'failed_findings',
|
||||
truncateText: true,
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.csp.findings.groupByResourceTable.failedFindingsColumnLabel"
|
||||
defaultMessage="Failed Findings"
|
||||
/>
|
||||
),
|
||||
render: (failedFindings: CspFindingsByResource['failed_findings']) => (
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTextColor color="danger">{formatNumber(failedFindings.total)}</EuiTextColor>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>({numeral(failedFindings.normalized).format('0%')})</span>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const FindingsByResourceTable = React.memo(FindingsByResourceTableComponent);
|
|
@ -4,7 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiComboBoxOptionOption, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
@ -16,20 +16,18 @@ import { FindingsTable } from './findings_table';
|
|||
import { FindingsSearchBar } from './findings_search_bar';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import { useUrlQuery } from '../../common/hooks/use_url_query';
|
||||
import { useFindings, type CspFindingsRequest } from './use_findings';
|
||||
import { useFindings } from './use_findings';
|
||||
import type { FindingsGroupByNoneQuery } from './use_findings';
|
||||
import type { FindingsBaseURLQuery } from './types';
|
||||
import { useFindingsByResource } from './use_findings_by_resource';
|
||||
import { FindingsGroupBySelector } from './findings_group_by_selector';
|
||||
import { INTERNAL_FEATURE_FLAGS } from '../../../common/constants';
|
||||
import { useFindingsCounter } from './use_findings_count';
|
||||
import { FindingsDistributionBar } from './findings_distribution_bar';
|
||||
import type { CspClientPluginStartDeps } from '../../types';
|
||||
import { useKibana } from '../../common/hooks/use_kibana';
|
||||
import * as TEXT from './translations';
|
||||
|
||||
export type GroupBy = 'none' | 'resourceType';
|
||||
export type FindingsBaseQuery = ReturnType<typeof getFindingsBaseEsQuery>;
|
||||
import { FindingsByResourceTable } from './findings_by_resource_table';
|
||||
|
||||
// TODO: define this as a schema with default values
|
||||
export const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({
|
||||
export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQuery => ({
|
||||
query: { language: 'kuery', query: '' },
|
||||
filters: [],
|
||||
sort: [{ ['@timestamp']: SortDirection.desc }],
|
||||
|
@ -38,7 +36,7 @@ export const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } =>
|
|||
groupBy: 'none',
|
||||
});
|
||||
|
||||
const getGroupByOptions = (): Array<EuiComboBoxOptionOption<GroupBy>> => [
|
||||
const getGroupByOptions = (): Array<EuiComboBoxOptionOption<FindingsBaseURLQuery['groupBy']>> => [
|
||||
{
|
||||
value: 'none',
|
||||
label: i18n.translate('xpack.csp.findings.groupBySelector.groupByNoneLabel', {
|
||||
|
@ -46,88 +44,55 @@ const getGroupByOptions = (): Array<EuiComboBoxOptionOption<GroupBy>> => [
|
|||
}),
|
||||
},
|
||||
{
|
||||
value: 'resourceType',
|
||||
label: i18n.translate('xpack.csp.findings.groupBySelector.groupByResourceTypeLabel', {
|
||||
defaultMessage: 'Resource Type',
|
||||
value: 'resource',
|
||||
label: i18n.translate('xpack.csp.findings.groupBySelector.groupByResourceIdLabel', {
|
||||
defaultMessage: 'Resource',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const getFindingsBaseEsQuery = ({
|
||||
query,
|
||||
dataView,
|
||||
filters,
|
||||
queryService,
|
||||
}: Pick<CspFindingsRequest, 'filters' | 'query'> & {
|
||||
dataView: DataView;
|
||||
queryService: CspClientPluginStartDeps['data']['query'];
|
||||
}) => {
|
||||
if (query) queryService.queryString.setQuery(query);
|
||||
queryService.filterManager.setFilters(filters);
|
||||
|
||||
try {
|
||||
return {
|
||||
index: dataView.title,
|
||||
query: buildEsQuery(
|
||||
dataView,
|
||||
queryService.queryString.getQuery(),
|
||||
queryService.filterManager.getFilters()
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(
|
||||
i18n.translate('xpack.csp.findings.unknownError', {
|
||||
defaultMessage: 'Unknown Error',
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const groupByOptions = useMemo(getGroupByOptions, []);
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
|
||||
|
||||
const {
|
||||
urlQuery: { groupBy, ...findingsQuery },
|
||||
setUrlQuery,
|
||||
} = useUrlQuery(getDefaultQuery);
|
||||
|
||||
const baseQuery = useMemo(
|
||||
() => getFindingsBaseEsQuery({ ...findingsQuery, dataView, queryService: data.query }),
|
||||
[data.query, dataView, findingsQuery]
|
||||
const baseEsQuery = useMemo(
|
||||
() => ({
|
||||
index: dataView.title,
|
||||
// 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
|
||||
query: buildEsQuery(dataView, urlQuery.query, urlQuery.filters),
|
||||
}),
|
||||
[dataView, urlQuery]
|
||||
);
|
||||
|
||||
const countResult = useFindingsCounter(baseQuery);
|
||||
const findingsResult = useFindings({
|
||||
...baseQuery,
|
||||
size: findingsQuery.size,
|
||||
from: findingsQuery.from,
|
||||
sort: findingsQuery.sort,
|
||||
const findingsGroupByResource = useFindingsByResource({
|
||||
...baseEsQuery,
|
||||
enabled: urlQuery.groupBy === 'resource',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (baseQuery.error) {
|
||||
toasts.addError(baseQuery.error, { title: TEXT.SEARCH_FAILED });
|
||||
}
|
||||
}, [baseQuery.error, toasts]);
|
||||
const findingsCount = useFindingsCounter({
|
||||
...baseEsQuery,
|
||||
enabled: urlQuery.groupBy === 'none',
|
||||
});
|
||||
|
||||
const findingsGroupByNone = useFindings({
|
||||
...baseEsQuery,
|
||||
enabled: urlQuery.groupBy === 'none',
|
||||
size: urlQuery.size,
|
||||
from: urlQuery.from,
|
||||
sort: urlQuery.sort,
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
|
||||
<FindingsSearchBar
|
||||
dataView={dataView}
|
||||
setQuery={setUrlQuery}
|
||||
query={findingsQuery.query}
|
||||
filters={findingsQuery.filters}
|
||||
loading={findingsResult.isLoading}
|
||||
query={urlQuery.query}
|
||||
filters={urlQuery.filters}
|
||||
loading={findingsGroupByNone.isLoading}
|
||||
/>
|
||||
<div
|
||||
css={css`
|
||||
|
@ -138,32 +103,41 @@ export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
|
|||
<EuiSpacer />
|
||||
{INTERNAL_FEATURE_FLAGS.showFindingsGroupBy && (
|
||||
<FindingsGroupBySelector
|
||||
type={groupBy}
|
||||
type={urlQuery.groupBy}
|
||||
onChange={(type) => setUrlQuery({ groupBy: type[0]?.value })}
|
||||
options={groupByOptions}
|
||||
/>
|
||||
)}
|
||||
<EuiSpacer />
|
||||
{groupBy === 'none' && (
|
||||
{urlQuery.groupBy === 'none' && (
|
||||
<>
|
||||
<FindingsDistributionBar
|
||||
total={findingsResult.data?.total || 0}
|
||||
passed={countResult.data?.passed || 0}
|
||||
failed={countResult.data?.failed || 0}
|
||||
pageStart={findingsQuery.from + 1} // API index is 0, but UI is 1
|
||||
pageEnd={findingsQuery.from + findingsQuery.size}
|
||||
total={findingsGroupByNone.data?.total || 0}
|
||||
passed={findingsCount.data?.passed || 0}
|
||||
failed={findingsCount.data?.failed || 0}
|
||||
pageStart={urlQuery.from + 1} // API index is 0, but UI is 1
|
||||
pageEnd={urlQuery.from + urlQuery.size}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
<FindingsTable
|
||||
{...findingsQuery}
|
||||
{...urlQuery}
|
||||
setQuery={setUrlQuery}
|
||||
data={findingsResult.data}
|
||||
error={findingsResult.error}
|
||||
loading={findingsResult.isLoading}
|
||||
data={findingsGroupByNone.data}
|
||||
error={findingsGroupByNone.error}
|
||||
loading={findingsGroupByNone.isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{urlQuery.groupBy === 'resource' && (
|
||||
<>
|
||||
<FindingsByResourceTable
|
||||
{...urlQuery}
|
||||
data={findingsGroupByResource.data}
|
||||
error={findingsGroupByResource.error}
|
||||
loading={findingsGroupByResource.isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{groupBy === 'resourceType' && <div />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -7,12 +7,12 @@
|
|||
import React from 'react';
|
||||
import { EuiComboBox, EuiFormLabel, type EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { GroupBy } from './findings_container';
|
||||
import type { FindingsGroupByKind } from './types';
|
||||
|
||||
interface Props {
|
||||
type: GroupBy;
|
||||
options: Array<EuiComboBoxOptionOption<GroupBy>>;
|
||||
onChange(selectedOptions: Array<EuiComboBoxOptionOption<GroupBy>>): void;
|
||||
type: FindingsGroupByKind;
|
||||
options: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>;
|
||||
onChange(selectedOptions: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>): void;
|
||||
}
|
||||
|
||||
export const FindingsGroupBySelector = ({ type, options, onChange }: Props) => (
|
||||
|
|
|
@ -10,12 +10,13 @@ import { EuiThemeComputed, useEuiTheme } from '@elastic/eui';
|
|||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import type { CspFindingsRequest, CspFindingsResult } from './use_findings';
|
||||
import type { CspFindingsResult } from './use_findings';
|
||||
import type { FindingsBaseURLQuery } from './types';
|
||||
import type { CspClientPluginStartDeps } from '../../types';
|
||||
import { PLUGIN_NAME } from '../../../common';
|
||||
import { FINDINGS_SEARCH_PLACEHOLDER } from './translations';
|
||||
|
||||
type SearchBarQueryProps = Pick<CspFindingsRequest, 'query' | 'filters'>;
|
||||
type SearchBarQueryProps = Pick<FindingsBaseURLQuery, 'query' | 'filters'>;
|
||||
|
||||
interface FindingsSearchBarProps extends SearchBarQueryProps {
|
||||
setQuery(v: Partial<SearchBarQueryProps>): void;
|
||||
|
|
|
@ -22,13 +22,11 @@ import * as TEST_SUBJECTS from './test_subjects';
|
|||
import * as TEXT from './translations';
|
||||
import type { CspFinding } from './types';
|
||||
import { CspEvaluationBadge } from '../../components/csp_evaluation_badge';
|
||||
import type { CspFindingsRequest, CspFindingsResult } from './use_findings';
|
||||
import type { FindingsGroupByNoneQuery, CspFindingsResult } from './use_findings';
|
||||
import { FindingsRuleFlyout } from './findings_flyout';
|
||||
|
||||
type TableQueryProps = Pick<CspFindingsRequest, 'sort' | 'from' | 'size'>;
|
||||
|
||||
interface BaseFindingsTableProps extends TableQueryProps {
|
||||
setQuery(query: Partial<TableQueryProps>): void;
|
||||
interface BaseFindingsTableProps extends FindingsGroupByNoneQuery {
|
||||
setQuery(query: Partial<FindingsGroupByNoneQuery>): void;
|
||||
}
|
||||
|
||||
type FindingsTableProps = CspFindingsResult & BaseFindingsTableProps;
|
||||
|
@ -119,7 +117,7 @@ const getEuiPaginationFromEsSearchSource = ({
|
|||
});
|
||||
|
||||
const getEuiSortFromEsSearchSource = (
|
||||
sort: TableQueryProps['sort']
|
||||
sort: FindingsGroupByNoneQuery['sort']
|
||||
): EuiBasicTableProps<CspFinding>['sorting'] => {
|
||||
if (!sort.length) return;
|
||||
|
||||
|
@ -133,7 +131,7 @@ const getEuiSortFromEsSearchSource = (
|
|||
const getEsSearchQueryFromEuiTableParams = ({
|
||||
page,
|
||||
sort,
|
||||
}: Criteria<CspFinding>): Partial<TableQueryProps> => ({
|
||||
}: Criteria<CspFinding>): Partial<FindingsGroupByNoneQuery> => ({
|
||||
...(!!page && { from: page.index * page.size, size: page.size }),
|
||||
sort: sort ? [{ [sort.field]: SortDirection[sort.direction] }] : undefined,
|
||||
});
|
||||
|
|
|
@ -9,3 +9,5 @@ export const FINDINGS_SEARCH_BAR = 'findings_search_bar';
|
|||
export const FINDINGS_TABLE = 'findings_table';
|
||||
export const FINDINGS_CONTAINER = 'findings_container';
|
||||
export const FINDINGS_TABLE_ZERO_STATE = 'findings_table_zero_state';
|
||||
export const getFindingsByResourceTableRowTestId = (id: string) =>
|
||||
`findings_resource_table_row_${id}`;
|
||||
|
|
|
@ -4,6 +4,33 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { BoolQuery, Filter, Query } from '@kbn/es-query';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
|
||||
export type FindingsGroupByKind = 'none' | 'resource';
|
||||
|
||||
export interface FindingsBaseURLQuery {
|
||||
groupBy: FindingsGroupByKind;
|
||||
query: Query;
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
export interface FindingsBaseEsQuery {
|
||||
index: string;
|
||||
query?: {
|
||||
bool: BoolQuery;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FindingsQueryStatus {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface FindingsQueryResult<TData = unknown, TError = unknown> {
|
||||
loading: UseQueryResult['isLoading'];
|
||||
error: TError;
|
||||
data: TData;
|
||||
}
|
||||
|
||||
// TODO: this needs to be defined in a versioned schema
|
||||
export interface CspFinding {
|
||||
|
|
|
@ -4,41 +4,35 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { type UseQueryResult, useQuery } from 'react-query';
|
||||
import { useQuery } from 'react-query';
|
||||
import { number } from 'io-ts';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import type {
|
||||
EsQuerySortValue,
|
||||
IEsSearchResponse,
|
||||
SerializedSearchSourceFields,
|
||||
} from '@kbn/data-plugin/common';
|
||||
import type { EsQuerySortValue, IEsSearchResponse } from '@kbn/data-plugin/common';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { extractErrorMessage } from '../../../common/utils/helpers';
|
||||
import * as TEXT from './translations';
|
||||
import type { CspFinding } from './types';
|
||||
import type { CspFinding, FindingsQueryResult } from './types';
|
||||
import { useKibana } from '../../common/hooks/use_kibana';
|
||||
import type { FindingsBaseQuery } from './findings_container';
|
||||
import type { FindingsBaseEsQuery, FindingsQueryStatus } from './types';
|
||||
|
||||
export interface CspFindingsRequest
|
||||
extends Required<Pick<SerializedSearchSourceFields, 'sort' | 'size' | 'from' | 'query'>> {
|
||||
filters: Filter[];
|
||||
interface UseFindingsOptions
|
||||
extends FindingsBaseEsQuery,
|
||||
FindingsGroupByNoneQuery,
|
||||
FindingsQueryStatus {}
|
||||
|
||||
export interface FindingsGroupByNoneQuery {
|
||||
from: NonNullable<estypes.SearchRequest['from']>;
|
||||
size: NonNullable<estypes.SearchRequest['size']>;
|
||||
sort: EsQuerySortValue[];
|
||||
}
|
||||
|
||||
type UseFindingsOptions = FindingsBaseQuery & Omit<CspFindingsRequest, 'filters' | 'query'>;
|
||||
|
||||
interface CspFindingsData {
|
||||
page: CspFinding[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
type Result = UseQueryResult<CspFindingsData, unknown>;
|
||||
|
||||
export interface CspFindingsResult {
|
||||
loading: Result['isLoading'];
|
||||
error: Result['error'];
|
||||
data: CspFindingsData | undefined;
|
||||
}
|
||||
export type CspFindingsResult = FindingsQueryResult<CspFindingsData | undefined, unknown>;
|
||||
|
||||
const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set(['@timestamp']);
|
||||
|
||||
|
@ -77,7 +71,7 @@ export const getFindingsQuery = ({
|
|||
size,
|
||||
from,
|
||||
sort,
|
||||
}: Omit<UseFindingsOptions, 'error'>) => ({
|
||||
}: Omit<UseFindingsOptions, 'enabled'>) => ({
|
||||
index,
|
||||
query,
|
||||
size,
|
||||
|
@ -85,14 +79,14 @@ export const getFindingsQuery = ({
|
|||
sort: mapEsQuerySortKey(sort),
|
||||
});
|
||||
|
||||
export const useFindings = ({ error, index, query, sort, from, size }: UseFindingsOptions) => {
|
||||
export const useFindings = ({ enabled, index, query, sort, from, size }: UseFindingsOptions) => {
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
return useQuery(
|
||||
['csp_findings', { from, size, query, sort }],
|
||||
['csp_findings', { index, query, sort, from, size }],
|
||||
() =>
|
||||
lastValueFrom<IEsSearchResponse<CspFinding>>(
|
||||
data.search.search({
|
||||
|
@ -100,9 +94,8 @@ export const useFindings = ({ error, index, query, sort, from, size }: UseFindin
|
|||
})
|
||||
),
|
||||
{
|
||||
enabled: !error,
|
||||
enabled,
|
||||
select: ({ rawResponse: { hits } }) => ({
|
||||
// TODO: use 'fields' instead of '_source' ?
|
||||
page: hits.hits.map((hit) => hit._source!),
|
||||
total: number.is(hits.total) ? hits.total : 0,
|
||||
}),
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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 { 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 { useKibana } from '../../common/hooks/use_kibana';
|
||||
import { showErrorToast } from './use_findings';
|
||||
import type { FindingsBaseEsQuery, FindingsQueryResult, FindingsQueryStatus } from './types';
|
||||
|
||||
interface UseFindingsByResourceOptions extends FindingsBaseEsQuery, FindingsQueryStatus {}
|
||||
|
||||
type FindingsAggRequest = IKibanaSearchRequest<estypes.SearchRequest>;
|
||||
type FindingsAggResponse = IKibanaSearchResponse<
|
||||
estypes.SearchResponse<{}, FindingsByResourceAggs>
|
||||
>;
|
||||
|
||||
export type CspFindingsByResourceResult = FindingsQueryResult<
|
||||
ReturnType<typeof useFindingsByResource>['data'] | undefined,
|
||||
unknown
|
||||
>;
|
||||
|
||||
interface FindingsByResourceAggs extends estypes.AggregationsCompositeAggregate {
|
||||
groupBy: {
|
||||
buckets: FindingsAggBucket[];
|
||||
};
|
||||
}
|
||||
|
||||
interface FindingsAggBucket {
|
||||
doc_count: number;
|
||||
failed_findings: { doc_count: number };
|
||||
key: {
|
||||
resource_id: string;
|
||||
cluster_id: string;
|
||||
cis_section: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const getFindingsByResourceAggQuery = ({
|
||||
index,
|
||||
query,
|
||||
}: Omit<UseFindingsByResourceOptions, 'enabled'>): estypes.SearchRequest => ({
|
||||
index,
|
||||
size: 0,
|
||||
body: {
|
||||
query,
|
||||
aggs: {
|
||||
groupBy: {
|
||||
composite: {
|
||||
size: 10 * 1000,
|
||||
sources: [
|
||||
{ resource_id: { terms: { field: 'resource_id.keyword' } } },
|
||||
{ cluster_id: { terms: { field: 'cluster_id.keyword' } } },
|
||||
{ cis_section: { terms: { field: 'rule.section' } } },
|
||||
],
|
||||
},
|
||||
aggs: {
|
||||
failed_findings: {
|
||||
filter: { term: { 'result.evaluation.keyword': 'failed' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useFindingsByResource = ({ enabled, index, query }: UseFindingsByResourceOptions) => {
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
return useQuery(
|
||||
['csp_findings_resource', { index, query }],
|
||||
() =>
|
||||
lastValueFrom(
|
||||
data.search.search<FindingsAggRequest, FindingsAggResponse>({
|
||||
params: getFindingsByResourceAggQuery({ index, query }),
|
||||
})
|
||||
),
|
||||
{
|
||||
enabled,
|
||||
select: ({ rawResponse }) => ({
|
||||
page: rawResponse.aggregations?.groupBy.buckets.map(createFindingsByResource) || [],
|
||||
}),
|
||||
onError: (err) => showErrorToast(toasts, err),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createFindingsByResource = (bucket: FindingsAggBucket) => ({
|
||||
...bucket.key,
|
||||
failed_findings: {
|
||||
total: bucket.failed_findings.doc_count,
|
||||
normalized: bucket.doc_count > 0 ? bucket.failed_findings.doc_count / bucket.doc_count : 0,
|
||||
},
|
||||
});
|
|
@ -10,7 +10,9 @@ import { lastValueFrom } from 'rxjs';
|
|||
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/public';
|
||||
import { useKibana } from '../../common/hooks/use_kibana';
|
||||
import { showErrorToast } from './use_findings';
|
||||
import type { FindingsBaseQuery } from './findings_container';
|
||||
import type { FindingsBaseEsQuery, FindingsQueryStatus } from './types';
|
||||
|
||||
interface UseFindingsCountOptions extends FindingsBaseEsQuery, FindingsQueryStatus {}
|
||||
|
||||
type FindingsAggRequest = IKibanaSearchRequest<estypes.SearchRequest>;
|
||||
type FindingsAggResponse = IKibanaSearchResponse<estypes.SearchResponse<{}, FindingsAggs>>;
|
||||
|
@ -23,7 +25,7 @@ interface FindingsAggs extends estypes.AggregationsMultiBucketAggregateBase {
|
|||
};
|
||||
}
|
||||
|
||||
export const getFindingsCountAggQuery = ({ index, query }: Omit<FindingsBaseQuery, 'error'>) => ({
|
||||
export const getFindingsCountAggQuery = ({ index, query }: FindingsBaseEsQuery) => ({
|
||||
index,
|
||||
size: 0,
|
||||
track_total_hits: true,
|
||||
|
@ -33,7 +35,7 @@ export const getFindingsCountAggQuery = ({ index, query }: Omit<FindingsBaseQuer
|
|||
},
|
||||
});
|
||||
|
||||
export const useFindingsCounter = ({ index, query, error }: FindingsBaseQuery) => {
|
||||
export const useFindingsCounter = ({ enabled, index, query }: UseFindingsCountOptions) => {
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
|
@ -48,7 +50,7 @@ export const useFindingsCounter = ({ index, query, error }: FindingsBaseQuery) =
|
|||
})
|
||||
),
|
||||
{
|
||||
enabled: !error,
|
||||
enabled,
|
||||
onError: (err) => showErrorToast(toasts, err),
|
||||
select: (response) =>
|
||||
Object.fromEntries(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue