diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/index.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/index.ts index 60a917846dc99..b026744f008bd 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/index.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/index.ts @@ -6,3 +6,5 @@ */ export * from './use_cloud_posture_data_table'; +export * from './use_base_es_query'; +export * from './use_persisted_query'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_base_es_query.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_base_es_query.ts new file mode 100644 index 0000000000000..4adffa100e48c --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_base_es_query.ts @@ -0,0 +1,81 @@ +/* + * 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 { buildEsQuery, EsQueryConfig } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { useEffect, useMemo } from 'react'; +import { FindingsBaseESQueryConfig, FindingsBaseProps, FindingsBaseURLQuery } from '../../types'; +import { useKibana } from '../use_kibana'; + +const getBaseQuery = ({ + dataView, + query, + filters, + config, +}: FindingsBaseURLQuery & FindingsBaseProps & FindingsBaseESQueryConfig) => { + try { + return { + query: buildEsQuery(dataView, query, filters, config), // will throw for malformed query + }; + } catch (error) { + return { + query: undefined, + error: error instanceof Error ? error : new Error('Unknown Error'), + }; + } +}; + +export const useBaseEsQuery = ({ + dataView, + filters = [], + query, + nonPersistedFilters, +}: FindingsBaseURLQuery & FindingsBaseProps) => { + const { + notifications: { toasts }, + data: { + query: { filterManager, queryString }, + }, + uiSettings, + } = useKibana().services; + const allowLeadingWildcards = uiSettings.get('query:allowLeadingWildcards'); + const config: EsQueryConfig = useMemo(() => ({ allowLeadingWildcards }), [allowLeadingWildcards]); + const baseEsQuery = useMemo( + () => + getBaseQuery({ + dataView, + filters: filters.concat(nonPersistedFilters ?? []).flat(), + query, + config, + }), + [dataView, filters, nonPersistedFilters, query, config] + ); + + /** + * Sync filters with the URL query + */ + useEffect(() => { + filterManager.setAppFilters(filters); + queryString.setQuery(query); + }, [filters, filterManager, queryString, query]); + + const handleMalformedQueryError = () => { + const error = baseEsQuery instanceof Error ? baseEsQuery : undefined; + if (error) { + toasts.addError(error, { + title: i18n.translate('xpack.csp.findings.search.queryErrorToastMessage', { + defaultMessage: 'Query Error', + }), + toastLifeTimeMs: 1000 * 5, + }); + } + }; + + useEffect(handleMalformedQueryError, [baseEsQuery, toasts]); + + return baseEsQuery; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts index b7b928a208c0a..ae21f45c7a4e8 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts @@ -11,9 +11,11 @@ import { CriteriaWithPagination } from '@elastic/eui'; import { DataTableRecord } from '@kbn/discover-utils/types'; import { useUrlQuery } from '../use_url_query'; import { usePageSize } from '../use_page_size'; -import { getDefaultQuery, useBaseEsQuery, usePersistedQuery } from './utils'; +import { getDefaultQuery } from './utils'; import { LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY } from '../../constants'; import { FindingsBaseURLQuery } from '../../types'; +import { useBaseEsQuery } from './use_base_es_query'; +import { usePersistedQuery } from './use_persisted_query'; type URLQuery = FindingsBaseURLQuery & Record; @@ -140,7 +142,16 @@ export const useCloudPostureDataTable = ({ setUrlQuery, sort: urlQuery.sort, filters: urlQuery.filters, - query: baseEsQuery.query, + query: baseEsQuery.query + ? baseEsQuery.query + : { + bool: { + must: [], + filter: [], + should: [], + must_not: [], + }, + }, queryError, pageIndex: urlQuery.pageIndex, urlQuery, diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_persisted_query.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_persisted_query.ts new file mode 100644 index 0000000000000..c3731c0139ce3 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_persisted_query.ts @@ -0,0 +1,28 @@ +/* + * 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 } from 'react'; +import { type Query } from '@kbn/es-query'; +import { FindingsBaseURLQuery } from '../../types'; +import { useKibana } from '../use_kibana'; + +export const usePersistedQuery = (getter: ({ filters, query }: FindingsBaseURLQuery) => T) => { + const { + data: { + query: { filterManager, queryString }, + }, + } = useKibana().services; + + return useCallback( + () => + getter({ + filters: filterManager.getAppFilters(), + query: queryString.getQuery() as Query, + }), + [getter, filterManager, queryString] + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts index e86d2a77589b0..c715b6a90b4ca 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts @@ -5,32 +5,7 @@ * 2.0. */ -import { useEffect, useCallback, useMemo } from 'react'; -import { buildEsQuery, EsQueryConfig } from '@kbn/es-query'; import type { EuiBasicTableProps, Pagination } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { type Query } from '@kbn/es-query'; -import { useKibana } from '../use_kibana'; -import type { - FindingsBaseESQueryConfig, - FindingsBaseProps, - FindingsBaseURLQuery, -} from '../../types'; - -const getBaseQuery = ({ - dataView, - query, - filters, - config, -}: FindingsBaseURLQuery & FindingsBaseProps & FindingsBaseESQueryConfig) => { - try { - return { - query: buildEsQuery(dataView, query, filters, config), // will throw for malformed query - }; - } catch (error) { - throw new Error(error); - } -}; type TablePagination = NonNullable['pagination']>; @@ -52,74 +27,6 @@ export const getPaginationQuery = ({ size: pageSize, }); -export const useBaseEsQuery = ({ - dataView, - filters = [], - query, - nonPersistedFilters, -}: FindingsBaseURLQuery & FindingsBaseProps) => { - const { - notifications: { toasts }, - data: { - query: { filterManager, queryString }, - }, - uiSettings, - } = useKibana().services; - const allowLeadingWildcards = uiSettings.get('query:allowLeadingWildcards'); - const config: EsQueryConfig = useMemo(() => ({ allowLeadingWildcards }), [allowLeadingWildcards]); - const baseEsQuery = useMemo( - () => - getBaseQuery({ - dataView, - filters: filters.concat(nonPersistedFilters ?? []).flat(), - query, - config, - }), - [dataView, filters, nonPersistedFilters, query, config] - ); - - /** - * Sync filters with the URL query - */ - useEffect(() => { - filterManager.setAppFilters(filters); - queryString.setQuery(query); - }, [filters, filterManager, queryString, query]); - - const handleMalformedQueryError = () => { - const error = baseEsQuery instanceof Error ? baseEsQuery : undefined; - if (error) { - toasts.addError(error, { - title: i18n.translate('xpack.csp.findings.search.queryErrorToastMessage', { - defaultMessage: 'Query Error', - }), - toastLifeTimeMs: 1000 * 5, - }); - } - }; - - useEffect(handleMalformedQueryError, [baseEsQuery, toasts]); - - return baseEsQuery; -}; - -export const usePersistedQuery = (getter: ({ filters, query }: FindingsBaseURLQuery) => T) => { - const { - data: { - query: { filterManager, queryString }, - }, - } = useKibana().services; - - return useCallback( - () => - getter({ - filters: filterManager.getAppFilters(), - query: queryString.getQuery() as Query, - }), - [getter, filterManager, queryString] - ); -}; - export const getDefaultQuery = ({ query, filters }: any): any => ({ query, filters, diff --git a/x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.test.ts b/x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.test.ts new file mode 100644 index 0000000000000..23cf2512ad2cf --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { getAbbreviatedNumber } from './get_abbreviated_number'; + +describe('getAbbreviatedNumber', () => { + it('should return the same value if it is less than 1000', () => { + expect(getAbbreviatedNumber(0)).toBe(0); + expect(getAbbreviatedNumber(1)).toBe(1); + expect(getAbbreviatedNumber(500)).toBe(500); + expect(getAbbreviatedNumber(999)).toBe(999); + }); + + it('should use numeral to format the value if it is greater than or equal to 1000', () => { + expect(getAbbreviatedNumber(1000)).toBe('1.0k'); + + expect(getAbbreviatedNumber(1200)).toBe('1.2k'); + + expect(getAbbreviatedNumber(3500000)).toBe('3.5m'); + + expect(getAbbreviatedNumber(2800000000)).toBe('2.8b'); + + expect(getAbbreviatedNumber(5900000000000)).toBe('5.9t'); + + expect(getAbbreviatedNumber(59000000000000000)).toBe('59000.0t'); + }); + + it('should return 0 if the value is NaN', () => { + expect(getAbbreviatedNumber(NaN)).toBe(0); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.ts b/x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.ts new file mode 100644 index 0000000000000..353a6482f4706 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.ts @@ -0,0 +1,22 @@ +/* + * 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 numeral from '@elastic/numeral'; + +/** + * Returns an abbreviated number when the value is greater than or equal to 1000. + * The abbreviated number is formatted using numeral: + * - thousand: k + * - million: m + * - billion: b + * - trillion: t + * */ +export const getAbbreviatedNumber = (value: number) => { + if (isNaN(value)) { + return 0; + } + return value < 1000 ? value : numeral(value).format('0.0a'); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx index b1f79779e65e4..ff411d2dcd9e0 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx @@ -8,13 +8,9 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiFlexItem } from '@elastic/eui'; import { type DataView } from '@kbn/data-views-plugin/common'; -import numeral from '@elastic/numeral'; import { FieldsSelectorModal } from './fields_selector'; import { useStyles } from './use_styles'; - -const formatNumber = (value: number) => { - return value < 1000 ? value : numeral(value).format('0.0a'); -}; +import { getAbbreviatedNumber } from '../../common/utils/get_abbreviated_number'; const GroupSelectorWrapper: React.FC = ({ children }) => { const styles = useStyles(); @@ -60,7 +56,7 @@ export const AdditionalControls = ({ /> )} - {`${formatNumber(total)} ${title}`} + {`${getAbbreviatedNumber(total)} ${title}`} { `; const groupBySelector = css` - width: 188px; margin-left: auto; `; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/cloud_security_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/cloud_security_grouping.tsx index 067046ce5457a..4ec3d3578a541 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/cloud_security_grouping.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/cloud_security_grouping.tsx @@ -9,6 +9,7 @@ import { ParsedGroupingAggregation } from '@kbn/securitysolution-grouping/src'; import { Filter } from '@kbn/es-query'; import React from 'react'; import { css } from '@emotion/react'; +import { CSP_GROUPING, CSP_GROUPING_LOADING } from '../test_subjects'; interface CloudSecurityGroupingProps { data: ParsedGroupingAggregation; @@ -20,8 +21,38 @@ interface CloudSecurityGroupingProps { onChangeGroupsItemsPerPage: (size: number) => void; onChangeGroupsPage: (index: number) => void; selectedGroup: string; + isGroupLoading?: boolean; } +/** + * This component is used to render the loading state of the CloudSecurityGrouping component + * It's used to avoid the flickering of the table when the data is loading + */ +const CloudSecurityGroupingLoading = ({ + grouping, + pageSize, +}: Pick) => { + return ( +
+ {grouping.getGrouping({ + activePage: 0, + data: { + groupsCount: { value: 1 }, + unitsCount: { value: 1 }, + }, + groupingLevel: 0, + inspectButton: undefined, + isLoading: true, + itemsPerPage: pageSize, + renderChildComponent: () => <>, + onGroupClose: () => {}, + selectedGroup: '', + takeActionItems: () => [], + })} +
+ ); +}; + export const CloudSecurityGrouping = ({ data, renderChildComponent, @@ -32,14 +63,22 @@ export const CloudSecurityGrouping = ({ onChangeGroupsItemsPerPage, onChangeGroupsPage, selectedGroup, + isGroupLoading, }: CloudSecurityGroupingProps) => { + if (isGroupLoading) { + return ; + } return (
.euiFlexItem:last-child { display: none; } + && [data-test-subj='group-stats'] > .euiFlexItem:not(:first-child) > span { + border-right: none; + margin-right: 0; + } `} > {grouping.getGrouping({ diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts index d2783af516e35..c59d382144524 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts @@ -4,16 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping'; import * as uuid from 'uuid'; import type { DataView } from '@kbn/data-views-plugin/common'; -import { useUrlQuery } from '../../common/hooks/use_url_query'; import { - useBaseEsQuery, - usePersistedQuery, -} from '../../common/hooks/use_cloud_posture_data_table/utils'; + GroupOption, + GroupPanelRenderer, + GroupStatsRenderer, +} from '@kbn/securitysolution-grouping/src'; +import { useUrlQuery } from '../../common/hooks/use_url_query'; + import { FindingsBaseURLQuery } from '../../common/types'; +import { useBaseEsQuery, usePersistedQuery } from '../../common/hooks/use_cloud_posture_data_table'; const DEFAULT_PAGE_SIZE = 10; const GROUPING_ID = 'cspLatestFindings'; @@ -28,19 +31,23 @@ export const useCloudSecurityGrouping = ({ defaultGroupingOptions, getDefaultQuery, unit, + groupPanelRenderer, + groupStatsRenderer, }: { dataView: DataView; groupingTitle: string; - defaultGroupingOptions: Array<{ label: string; key: string }>; + defaultGroupingOptions: GroupOption[]; getDefaultQuery: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery; unit: (count: number) => string; + groupPanelRenderer?: GroupPanelRenderer; + groupStatsRenderer?: GroupStatsRenderer; }) => { const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery); const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); const [activePageIndex, setActivePageIndex] = useState(0); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const { query } = useBaseEsQuery({ + const { query, error } = useBaseEsQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query, @@ -57,6 +64,8 @@ export const useCloudSecurityGrouping = ({ const grouping = useGrouping({ componentProps: { unit, + groupPanelRenderer, + groupStatsRenderer, }, defaultGroupingOptions, fields: dataView.fields, @@ -81,6 +90,16 @@ export const useCloudSecurityGrouping = ({ setPageSize(size); }; + const onResetFilters = useCallback(() => { + setUrlQuery({ + filters: [], + query: { + query: '', + language: 'kuery', + }, + }); + }, [setUrlQuery]); + const onChangeGroupsPage = (index: number) => setActivePageIndex(index); return { @@ -88,11 +107,14 @@ export const useCloudSecurityGrouping = ({ grouping, pageSize, query, + error, selectedGroup, setUrlQuery, uniqueValue, isNoneSelected, onChangeGroupsItemsPerPage, onChangeGroupsPage, + onResetFilters, + filters: urlQuery.filters, }; }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx b/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx index 8f866079ab160..d71d6c2e2384b 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/compliance_score_bar.tsx @@ -6,11 +6,12 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; +import { css, SerializedStyles } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { calculatePostureScore } from '../../common/utils/helpers'; import { statusColors } from '../common/constants'; +import { CSP_FINDINGS_COMPLIANCE_SCORE } from './test_subjects'; /** * This component will take 100% of the width set by the parent @@ -18,20 +19,26 @@ import { statusColors } from '../common/constants'; export const ComplianceScoreBar = ({ totalPassed, totalFailed, + size = 'm', + overrideCss, }: { totalPassed: number; totalFailed: number; + size?: 'm' | 'l'; + overrideCss?: SerializedStyles; }) => { const { euiTheme } = useEuiTheme(); const complianceScore = calculatePostureScore(totalPassed, totalFailed); + // ensures the compliance bar takes full width of its parent + const fullWidthTooltipCss = css` + width: 100%; + `; + return ( - + {!!totalPassed && ( )} {!!totalFailed && ( )} - + {`${complianceScore.toFixed(0)}%`} diff --git a/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts index 91589cee3dcfc..1f603a67ae1fc 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts @@ -39,3 +39,7 @@ export const VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ = 'vulnerabilities_cvss_score export const TAKE_ACTION_SUBJ = 'csp:take_action'; export const CREATE_RULE_ACTION_SUBJ = 'csp:create_rule'; + +export const CSP_GROUPING = 'cloudSecurityGrouping'; +export const CSP_GROUPING_LOADING = 'cloudSecurityGroupingLoading'; +export const CSP_FINDINGS_COMPLIANCE_SCORE = 'cloudSecurityFindingsComplianceScore'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts index 54884856fccf1..e2e4585906bae 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { GroupOption } from '@kbn/securitysolution-grouping'; import { FindingsBaseURLQuery } from '../../../common/types'; import { CloudSecurityDefaultColumn } from '../../../components/cloud_security_data_table'; @@ -15,30 +16,60 @@ export const FINDINGS_UNIT = (totalCount: number) => defaultMessage: `{totalCount, plural, =1 {finding} other {findings}}`, }); -export const defaultGroupingOptions = [ +export const GROUPING_OPTIONS = { + RESOURCE_NAME: 'resource.name', + RULE_NAME: 'rule.name', + CLOUD_ACCOUNT_NAME: 'cloud.account.name', + ORCHESTRATOR_CLUSTER_NAME: 'orchestrator.cluster.name', +}; + +export const NULL_GROUPING_UNIT = i18n.translate('xpack.csp.findings.grouping.nullGroupUnit', { + defaultMessage: 'findings', +}); + +export const NULL_GROUPING_MESSAGES = { + RESOURCE_NAME: i18n.translate('xpack.csp.findings.grouping.resource.nullGroupTitle', { + defaultMessage: 'No resource', + }), + RULE_NAME: i18n.translate('xpack.csp.findings.grouping.rule.nullGroupTitle', { + defaultMessage: 'No rule', + }), + CLOUD_ACCOUNT_NAME: i18n.translate('xpack.csp.findings.grouping.cloudAccount.nullGroupTitle', { + defaultMessage: 'No cloud account', + }), + ORCHESTRATOR_CLUSTER_NAME: i18n.translate( + 'xpack.csp.findings.grouping.kubernetes.nullGroupTitle', + { defaultMessage: 'No Kubernetes cluster' } + ), + DEFAULT: i18n.translate('xpack.csp.findings.grouping.default.nullGroupTitle', { + defaultMessage: 'No grouping', + }), +}; + +export const defaultGroupingOptions: GroupOption[] = [ { label: i18n.translate('xpack.csp.findings.latestFindings.groupByResource', { defaultMessage: 'Resource', }), - key: 'resource.name', + key: GROUPING_OPTIONS.RESOURCE_NAME, }, { label: i18n.translate('xpack.csp.findings.latestFindings.groupByRuleName', { defaultMessage: 'Rule name', }), - key: 'rule.name', + key: GROUPING_OPTIONS.RULE_NAME, }, { label: i18n.translate('xpack.csp.findings.latestFindings.groupByCloudAccount', { defaultMessage: 'Cloud account', }), - key: 'cloud.account.name', + key: GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME, }, { label: i18n.translate('xpack.csp.findings.latestFindings.groupByKubernetesCluster', { defaultMessage: 'Kubernetes cluster', }), - key: 'orchestrator.cluster.name', + key: GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME, }, ]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx index 613594c66e939..9d536f0f0b180 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx @@ -6,13 +6,17 @@ */ import React, { useCallback } from 'react'; import { Filter } from '@kbn/es-query'; -import { defaultLoadingRenderer } from '../../../components/cloud_posture_page'; +import { EuiSpacer } from '@elastic/eui'; +import { EmptyState } from '../../../components/empty_state'; import { CloudSecurityGrouping } from '../../../components/cloud_security_grouping'; import type { FindingsBaseProps } from '../../../common/types'; import { FindingsSearchBar } from '../layout/findings_search_bar'; import { DEFAULT_TABLE_HEIGHT } from './constants'; import { useLatestFindingsGrouping } from './use_latest_findings_grouping'; import { LatestFindingsTable } from './latest_findings_table'; +import { groupPanelRenderer, groupStatsRenderer } from './latest_findings_group_renderer'; +import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; +import { ErrorCallout } from '../layout/error_callout'; export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { const renderChildComponent = useCallback( @@ -30,7 +34,7 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { ); const { - isGroupSelect, + isGroupSelected, groupData, grouping, isFetching, @@ -41,26 +45,49 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { onChangeGroupsPage, setUrlQuery, isGroupLoading, - } = useLatestFindingsGrouping({ dataView }); + onResetFilters, + error, + totalPassedFindings, + onDistributionBarClick, + totalFailedFindings, + isEmptyResults, + } = useLatestFindingsGrouping({ dataView, groupPanelRenderer, groupStatsRenderer }); - if (isGroupSelect) { - return isGroupLoading ? ( - defaultLoadingRenderer() - ) : ( -
+ if (error || isEmptyResults) { + return ( + <> - -
+ + {error && } + {isEmptyResults && } + + ); + } + if (isGroupSelected) { + return ( + <> + +
+ + + +
+ ); } diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx new file mode 100644 index 0000000000000..d0684452fb23a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx @@ -0,0 +1,312 @@ +/* + * 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 { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiSkeletonTitle, + EuiText, + EuiTextBlockTruncate, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { + ECSField, + GroupPanelRenderer, + RawBucket, + StatRenderer, +} from '@kbn/securitysolution-grouping/src'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { getAbbreviatedNumber } from '../../../common/utils/get_abbreviated_number'; +import { CISBenchmarkIcon } from '../../../components/cis_benchmark_icon'; +import { ComplianceScoreBar } from '../../../components/compliance_score_bar'; +import { FindingsGroupingAggregation } from './use_grouped_findings'; +import { GROUPING_OPTIONS, NULL_GROUPING_MESSAGES, NULL_GROUPING_UNIT } from './constants'; +import { FINDINGS_GROUPING_COUNTER } from '../test_subjects'; + +/** + * Return first non-null value. If the field contains an array, this will return the first value that isn't null. If the field isn't an array it'll be returned unless it's null. + */ +export function firstNonNullValue(valueOrCollection: ECSField): T | undefined { + if (valueOrCollection === null) { + return undefined; + } else if (Array.isArray(valueOrCollection)) { + for (const value of valueOrCollection) { + if (value !== null) { + return value; + } + } + } else { + return valueOrCollection; + } +} + +const NullGroupComponent = ({ + title, + field, + unit = NULL_GROUPING_UNIT, +}: { + title: string; + field: string; + unit?: string; +}) => { + return ( + + {title} + + + + + ), + field: {field}, + unit, + }} + /> + + } + position="right" + /> + + ); +}; + +export const groupPanelRenderer: GroupPanelRenderer = ( + selectedGroup, + bucket, + nullGroupMessage, + isLoading +) => { + if (isLoading) { + return ( + + + + ); + } + const benchmarkId = firstNonNullValue(bucket.benchmarkId?.buckets?.[0]?.key); + switch (selectedGroup) { + case GROUPING_OPTIONS.RESOURCE_NAME: + return nullGroupMessage ? ( + + ) : ( + + + + + + + {bucket.key_as_string} {bucket.resourceName?.buckets?.[0].key} + + + + + + {bucket.resourceSubType?.buckets?.[0].key} + + + + + + ); + case GROUPING_OPTIONS.RULE_NAME: + return nullGroupMessage ? ( + + ) : ( + + + + + + {bucket.key_as_string} + + + + + {firstNonNullValue(bucket.benchmarkName?.buckets?.[0].key)}{' '} + {firstNonNullValue(bucket.benchmarkVersion?.buckets?.[0].key)} + + + + + + ); + case GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME: + return nullGroupMessage ? ( + + ) : ( + + {benchmarkId && ( + + + + )} + + + + + {bucket.key_as_string} + + + + + {bucket.benchmarkName?.buckets?.[0]?.key} + + + + + + ); + case GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME: + return nullGroupMessage ? ( + + ) : ( + + {benchmarkId && ( + + + + )} + + + + + {bucket.key_as_string} + + + + + {bucket.benchmarkName?.buckets?.[0]?.key} + + + + + + ); + default: + return nullGroupMessage ? ( + + ) : ( + + + + + + {bucket.key_as_string} + + + + + + ); + } +}; + +const FindingsCountComponent = ({ bucket }: { bucket: RawBucket }) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + {getAbbreviatedNumber(bucket.doc_count)} + + + ); +}; + +const FindingsCount = React.memo(FindingsCountComponent); + +const ComplianceBarComponent = ({ bucket }: { bucket: RawBucket }) => { + const { euiTheme } = useEuiTheme(); + + const totalFailed = bucket.failedFindings?.doc_count || 0; + const totalPassed = bucket.doc_count - totalFailed; + return ( + + ); +}; + +const ComplianceBar = React.memo(ComplianceBarComponent); + +export const groupStatsRenderer = ( + selectedGroup: string, + bucket: RawBucket +): StatRenderer[] => { + const defaultBadges = [ + { + title: i18n.translate('xpack.csp.findings.grouping.stats.badges.findings', { + defaultMessage: 'Findings', + }), + renderer: , + }, + { + title: i18n.translate('xpack.csp.findings.grouping.stats.badges.compliance', { + defaultMessage: 'Compliance', + }), + renderer: , + }, + ]; + + return defaultBadges; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx index a66ce54fa99cc..be6d34a1df933 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx @@ -123,7 +123,7 @@ export const LatestFindingsTable = ({ passed={passed} failed={failed} /> - + )} { + failedFindings?: { + doc_count?: NumberOrNull; + }; + passedFindings?: { + doc_count?: NumberOrNull; + }; +} + export interface FindingsGroupingAggregation { unitsCount?: { value?: NumberOrNull; @@ -25,9 +34,37 @@ export interface FindingsGroupingAggregation { groupsCount?: { value?: NumberOrNull; }; + failedFindings?: { + doc_count?: NumberOrNull; + }; + passedFindings?: { + doc_count?: NumberOrNull; + }; groupByFields?: { buckets?: GenericBuckets[]; }; + description?: { + buckets?: GenericBuckets[]; + }; + resourceName?: { + buckets?: GenericBuckets[]; + }; + resourceSubType?: { + buckets?: GenericBuckets[]; + }; + resourceType?: { + buckets?: GenericBuckets[]; + }; + benchmarkName?: { + buckets?: GenericBuckets[]; + }; + benchmarkVersion?: { + buckets?: GenericBuckets[]; + }; + benchmarkId?: { + buckets?: GenericBuckets[]; + }; + isLoading?: boolean; } export const getGroupedFindingsQuery = (query: GroupingQuery) => ({ @@ -56,9 +93,7 @@ export const useGroupedFindings = ({ } = await lastValueFrom( data.search.search< {}, - IKibanaSearchResponse< - SearchResponse<{}, GroupingAggregation> - > + IKibanaSearchResponse> >({ params: getGroupedFindingsQuery(query), }) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx index 137716967460f..be5253cc710b7 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx @@ -5,19 +5,131 @@ * 2.0. */ import { getGroupingQuery } from '@kbn/securitysolution-grouping'; -import { parseGroupingQuery } from '@kbn/securitysolution-grouping/src'; +import { + GroupingAggregation, + GroupPanelRenderer, + GroupStatsRenderer, + isNoneGroup, + NamedAggregation, + parseGroupingQuery, +} from '@kbn/securitysolution-grouping/src'; import { useMemo } from 'react'; import { DataView } from '@kbn/data-views-plugin/common'; +import { Evaluation } from '../../../../common/types'; import { LATEST_FINDINGS_RETENTION_POLICY } from '../../../../common/constants'; -import { useGroupedFindings } from './use_grouped_findings'; -import { FINDINGS_UNIT, groupingTitle, defaultGroupingOptions, getDefaultQuery } from './constants'; +import { + FindingsGroupingAggregation, + FindingsRootGroupingAggregation, + useGroupedFindings, +} from './use_grouped_findings'; +import { + FINDINGS_UNIT, + groupingTitle, + defaultGroupingOptions, + getDefaultQuery, + GROUPING_OPTIONS, +} from './constants'; import { useCloudSecurityGrouping } from '../../../components/cloud_security_grouping'; +import { getFilters } from '../utils/get_filters'; + +const getTermAggregation = (key: keyof FindingsGroupingAggregation, field: string) => ({ + [key]: { + terms: { field, size: 1 }, + }, +}); + +const getAggregationsByGroupField = (field: string): NamedAggregation[] => { + if (isNoneGroup([field])) { + return []; + } + const aggMetrics: NamedAggregation[] = [ + { + groupByField: { + cardinality: { + field, + }, + }, + failedFindings: { + filter: { + term: { + 'result.evaluation': { value: 'failed' }, + }, + }, + }, + passedFindings: { + filter: { + term: { + 'result.evaluation': { value: 'passed' }, + }, + }, + }, + complianceScore: { + bucket_script: { + buckets_path: { + passed: 'passedFindings>_count', + failed: 'failedFindings>_count', + }, + script: 'params.passed / (params.passed + params.failed)', + }, + }, + }, + ]; + + switch (field) { + case GROUPING_OPTIONS.RESOURCE_NAME: + return [ + ...aggMetrics, + getTermAggregation('resourceName', 'resource.id'), + getTermAggregation('resourceSubType', 'resource.sub_type'), + getTermAggregation('resourceType', 'resource.type'), + ]; + case GROUPING_OPTIONS.RULE_NAME: + return [ + ...aggMetrics, + getTermAggregation('benchmarkName', 'rule.benchmark.name'), + getTermAggregation('benchmarkVersion', 'rule.benchmark.version'), + ]; + case GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME: + return [ + ...aggMetrics, + getTermAggregation('benchmarkName', 'rule.benchmark.name'), + getTermAggregation('benchmarkId', 'rule.benchmark.id'), + ]; + case GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME: + return [ + ...aggMetrics, + getTermAggregation('benchmarkName', 'rule.benchmark.name'), + getTermAggregation('benchmarkId', 'rule.benchmark.id'), + ]; + } + return aggMetrics; +}; + +/** + * Type Guard for checking if the given source is a FindingsRootGroupingAggregation + */ +export const isFindingsRootGroupingAggregation = ( + groupData: Record | undefined +): groupData is FindingsRootGroupingAggregation => { + return ( + groupData?.passedFindings?.doc_count !== undefined && + groupData?.failedFindings?.doc_count !== undefined + ); +}; /** * Utility hook to get the latest findings grouping data * for the findings page */ -export const useLatestFindingsGrouping = ({ dataView }: { dataView: DataView }) => { +export const useLatestFindingsGrouping = ({ + dataView, + groupPanelRenderer, + groupStatsRenderer, +}: { + dataView: DataView; + groupPanelRenderer?: GroupPanelRenderer; + groupStatsRenderer?: GroupStatsRenderer; +}) => { const { activePageIndex, grouping, @@ -29,23 +141,47 @@ export const useLatestFindingsGrouping = ({ dataView }: { dataView: DataView }) setUrlQuery, uniqueValue, isNoneSelected, + onResetFilters, + error, + filters, } = useCloudSecurityGrouping({ dataView, groupingTitle, defaultGroupingOptions, getDefaultQuery, unit: FINDINGS_UNIT, + groupPanelRenderer, + groupStatsRenderer, }); const groupingQuery = getGroupingQuery({ - additionalFilters: [query], + additionalFilters: query ? [query] : [], groupByField: selectedGroup, uniqueValue, from: `now-${LATEST_FINDINGS_RETENTION_POLICY}`, to: 'now', pageNumber: activePageIndex * pageSize, size: pageSize, - sort: [{ _key: { order: 'asc' } }], + sort: [{ groupByField: { order: 'desc' } }, { complianceScore: { order: 'asc' } }], + statsAggregations: getAggregationsByGroupField(selectedGroup), + rootAggregations: [ + { + failedFindings: { + filter: { + term: { + 'result.evaluation': { value: 'failed' }, + }, + }, + }, + passedFindings: { + filter: { + term: { + 'result.evaluation': { value: 'passed' }, + }, + }, + }, + }, + ], }); const { data, isFetching } = useGroupedFindings({ @@ -54,10 +190,37 @@ export const useLatestFindingsGrouping = ({ dataView }: { dataView: DataView }) }); const groupData = useMemo( - () => parseGroupingQuery(selectedGroup, uniqueValue, data), + () => + parseGroupingQuery( + selectedGroup, + uniqueValue, + data as GroupingAggregation + ), [data, selectedGroup, uniqueValue] ); + const totalPassedFindings = isFindingsRootGroupingAggregation(groupData) + ? groupData?.passedFindings?.doc_count || 0 + : 0; + const totalFailedFindings = isFindingsRootGroupingAggregation(groupData) + ? groupData?.failedFindings?.doc_count || 0 + : 0; + + const onDistributionBarClick = (evaluation: Evaluation) => { + setUrlQuery({ + filters: getFilters({ + filters, + dataView, + field: 'result.evaluation', + value: evaluation, + negate: false, + }), + }); + }; + + const isEmptyResults = + !isFetching && isFindingsRootGroupingAggregation(groupData) && !groupData.unitsCount?.value; + return { groupData, grouping, @@ -68,7 +231,14 @@ export const useLatestFindingsGrouping = ({ dataView }: { dataView: DataView }) onChangeGroupsItemsPerPage, onChangeGroupsPage, setUrlQuery, - isGroupSelect: !isNoneSelected, + isGroupSelected: !isNoneSelected, isGroupLoading: !data, + onResetFilters, + filters, + error, + onDistributionBarClick, + totalPassedFindings, + totalFailedFindings, + isEmptyResults, }; }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx index 7f483c3ee0847..3054ad352a5bd 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx @@ -39,6 +39,9 @@ const getDefaultQuery = ({ sort: { field: 'compliance_score' as keyof CspFinding, direction: 'asc' }, }); +/** + * @deprecated: This component is deprecated and will be removed in the next release. + */ export const FindingsByResourceContainer = ({ dataView }: FindingsBaseProps) => ( ); +/** + * @deprecated: This component is deprecated and will be removed in the next release. + */ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => { const { queryError, query, pageSize, setTableOptions, urlQuery, setUrlQuery, onResetFilters } = useCloudPostureTable({ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.tsx index ce3f55e03417d..71c4219d3b852 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.tsx @@ -29,6 +29,10 @@ import { } from '../layout/findings_layout'; import { EmptyState } from '../../../components/empty_state'; +/** + * @deprecated: This function is deprecated and will be removed in the next release. + * use getAbbreviatedNumber from x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.ts + */ export const formatNumber = (value: number) => value < 1000 ? value : numeral(value).format('0.0a'); @@ -44,11 +48,17 @@ interface Props { onResetFilters: () => void; } +/** + * @deprecated: This function is deprecated and will be removed in the next release. + */ export const getResourceId = (resource: FindingsByResourcePage) => { const sections = resource['rule.section'] || []; return [resource.resource_id, ...sections].join('/'); }; +/** + * @deprecated: This component is deprecated and will be removed in the next release. + */ const FindingsByResourceTableComponent = ({ items, loading, @@ -189,8 +199,14 @@ const baseColumns: Array> = type BaseFindingColumnName = typeof baseColumns[number]['field']; +/** + * @deprecated: This function is deprecated and will be removed in the next release. + */ export const findingsByResourceColumns = Object.fromEntries( baseColumns.map((column) => [column.field, column]) ) as Record; +/** + * @deprecated: This component is deprecated and will be removed in the next release. + */ export const FindingsByResourceTable = React.memo(FindingsByResourceTableComponent); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx index 83182d66665d7..f606241862ee2 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx @@ -95,6 +95,9 @@ const getResourceFindingSharedValues = (sharedValues: { }, ]; +/** + * @deprecated: This component is deprecated and will be removed in the next release. + */ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { const params = useParams<{ resourceId: string }>(); const decodedResourceId = decodeURIComponent(params.resourceId); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.tsx index 09f046f504ea7..4dd7070af88f1 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.tsx @@ -106,4 +106,7 @@ const ResourceFindingsTableComponent = ({ ); }; +/** + * @deprecated: This component is deprecated and will be removed in the next release. + */ export const ResourceFindingsTable = React.memo(ResourceFindingsTableComponent); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts index cd1a3484548c7..46a5e12665660 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts @@ -80,6 +80,9 @@ const getResourceFindingsQuery = ({ ignore_unavailable: false, }); +/** + * @deprecated: This hook is deprecated and will be removed in the next release. + */ export const useResourceFindings = (options: UseResourceFindingsOptions) => { const { data, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts index d0253966bc87c..e4bbd955f6092 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts @@ -76,6 +76,9 @@ interface FindingsAggBucket extends AggregationsStringRareTermsBucketKeys { cis_sections: AggregationsMultiBucketAggregateBase; } +/** + * @deprecated: This hook is deprecated and will be removed in the next release. + */ export const getFindingsByResourceAggQuery = ({ query, sortDirection, @@ -168,6 +171,9 @@ const createFindingsByResource = (resource: FindingsAggBucket): FindingsByResour }, }); +/** + * @deprecated: This hook is deprecated and will be removed in the next release. + */ export const useFindingsByResource = (options: UseFindingsByResourceOptions) => { const { data, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx index 294b05d9dd802..1cf084220ea29 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import numeral from '@elastic/numeral'; +import { getAbbreviatedNumber } from '../../../common/utils/get_abbreviated_number'; import { RULE_FAILED, RULE_PASSED } from '../../../../common/constants'; import { statusColors } from '../../../common/constants'; import type { Evaluation } from '../../../../common/types'; @@ -28,8 +28,6 @@ interface Props { distributionOnClick: (evaluation: Evaluation) => void; } -const formatNumber = (value: number) => (value < 1000 ? value : numeral(value).format('0.0a')); - export const CurrentPageOfTotal = ({ pageEnd, pageStart, @@ -48,7 +46,7 @@ export const CurrentPageOfTotal = ({ values={{ pageStart: {pageStart}, pageEnd: {pageEnd}, - total: {formatNumber(total)}, + total: {getAbbreviatedNumber(total)}, type, }} /> @@ -113,7 +111,7 @@ const DistributionBar: React.FC> = ({ gutterSize="none" css={css` height: 8px; - background: ${euiTheme.colors.subduedText}; + background: ${euiTheme.colors.lightestShade}; `} > {label} - {formatNumber(value)} + {getAbbreviatedNumber(value)}
); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/test_subjects.ts index b0d5a8ffd19f5..b465b58f45887 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/test_subjects.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/test_subjects.ts @@ -20,6 +20,7 @@ export const LATEST_FINDINGS_CONTAINER = 'latest_findings_container'; export const LATEST_FINDINGS_TABLE = 'latest_findings_table'; export const FINDINGS_GROUP_BY_SELECTOR = 'findings_group_by_selector'; +export const FINDINGS_GROUPING_COUNTER = 'findings_grouping_counter'; export const getFindingsTableRowTestId = (id: string) => `findings_table_row_${id}`; export const getFindingsTableCellTestId = (columnId: string, rowId: string) => diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx index 966f95cb367fa..67e46d82020cc 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx @@ -99,16 +99,6 @@ export const Findings = () => { - - - { defaultMessage="Misconfigurations" /> + + + )} diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts b/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts index 173630e56837e..2939f3eed9266 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts @@ -17,13 +17,17 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['common', 'findings', 'header']); const chance = new Chance(); + const cspmResourceId = chance.guid(); + const cspmResourceName = 'gcp-resource'; + const cspmResourceSubType = 'gcp-monitoring'; + // We need to use a dataset for the tests to run // We intentionally make some fields start with a capital letter to test that the query bar is case-insensitive/case-sensitive const data = [ { '@timestamp': new Date().toISOString(), resource: { id: chance.guid(), name: `kubelet`, sub_type: 'lower case sub type' }, - result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, + result: { evaluation: 'failed' }, orchestrator: { cluster: { id: '1', @@ -41,16 +45,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, type: 'process', }, - cluster_id: 'Upper case cluster id', }, { '@timestamp': new Date().toISOString(), resource: { id: chance.guid(), name: `Pod`, sub_type: 'Upper case sub type' }, - result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, - cloud: { - account: { + result: { evaluation: 'passed' }, + orchestrator: { + cluster: { id: '1', - name: 'Account 1', + name: 'Cluster 2', }, }, rule: { @@ -64,11 +67,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, type: 'process', }, - cluster_id: 'Another Upper case cluster id', }, { '@timestamp': new Date().toISOString(), - resource: { id: chance.guid(), name: `process`, sub_type: 'another lower case type' }, + resource: { id: cspmResourceId, name: cspmResourceName, sub_type: cspmResourceSubType }, result: { evaluation: 'passed' }, cloud: { account: { @@ -80,18 +82,17 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { name: 'Another upper case rule name', section: 'lower case section', benchmark: { - id: 'cis_k8s', - posture_type: 'kspm', - name: 'CIS Kubernetes V1.23', - version: 'v1.0.0', + id: 'cis_gcp', + posture_type: 'cspm', + name: 'CIS Google Cloud Platform Foundation', + version: 'v2.0.0', }, type: 'process', }, - cluster_id: 'lower case cluster id', }, { '@timestamp': new Date().toISOString(), - resource: { id: chance.guid(), name: `process`, sub_type: 'Upper case type again' }, + resource: { id: cspmResourceId, name: cspmResourceName, sub_type: cspmResourceSubType }, result: { evaluation: 'failed' }, cloud: { account: { @@ -103,14 +104,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { name: 'some lower case rule name', section: 'another lower case section', benchmark: { - id: 'cis_k8s', - posture_type: 'kspm', - name: 'CIS Kubernetes V1.23', - version: 'v1.0.0', + id: 'cis_gcp', + posture_type: 'cspm', + name: 'CIS Google Cloud Platform Foundation', + version: 'v2.0.0', }, type: 'process', }, - cluster_id: 'another lower case cluster id', }, ]; @@ -143,19 +143,57 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); describe('Default Grouping', async () => { - it('groups findings by resource and sort case sensitive asc', async () => { + it('groups findings by resource and sort by compliance score desc', async () => { const groupSelector = await findings.groupSelector(); await groupSelector.openDropDown(); await groupSelector.setValue('Resource'); const grouping = await findings.findingsGrouping(); - const resourceOrder = ['Pod', 'kubelet', 'process']; + const resourceOrder = [ + { + resourceName: 'kubelet', + resourceId: data[0].resource.id, + resourceSubType: data[0].resource.sub_type, + findingsCount: '1', + complianceScore: '0%', + }, + { + resourceName: cspmResourceName, + resourceId: cspmResourceId, + resourceSubType: cspmResourceSubType, + findingsCount: '2', + complianceScore: '50%', + }, + { + resourceName: 'Pod', + resourceId: data[1].resource.id, + resourceSubType: data[1].resource.sub_type, + findingsCount: '1', + complianceScore: '100%', + }, + ]; - await asyncForEach(resourceOrder, async (resourceName, index) => { - const groupName = await grouping.getRowAtIndex(index); - expect(await groupName.getVisibleText()).to.be(resourceName); - }); + await asyncForEach( + resourceOrder, + async ( + { resourceName, resourceId, resourceSubType, findingsCount, complianceScore }, + index + ) => { + const groupRow = await grouping.getRowAtIndex(index); + expect(await groupRow.getVisibleText()).to.contain(resourceName); + expect(await groupRow.getVisibleText()).to.contain(resourceId); + expect(await groupRow.getVisibleText()).to.contain(resourceSubType); + expect( + await ( + await groupRow.findByTestSubject('cloudSecurityFindingsComplianceScore') + ).getVisibleText() + ).to.be(complianceScore); + expect( + await (await groupRow.findByTestSubject('findings_grouping_counter')).getVisibleText() + ).to.be(findingsCount); + } + ); const groupCount = await grouping.getGroupCount(); expect(groupCount).to.be('3 groups'); @@ -163,7 +201,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const unitCount = await grouping.getUnitCount(); expect(unitCount).to.be('4 findings'); }); - it('groups findings by rule name and sort case sensitive asc', async () => { + it('groups findings by rule name and sort by compliance score desc', async () => { const groupSelector = await findings.groupSelector(); await groupSelector.openDropDown(); await groupSelector.setValue('Rule name'); @@ -177,18 +215,50 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(unitCount).to.be('4 findings'); const ruleNameOrder = [ - 'Another upper case rule name', - 'Upper case rule name', - 'lower case rule name', - 'some lower case rule name', + { + ruleName: 'Upper case rule name', + findingsCount: '1', + complianceScore: '0%', + benchmarkName: data[0].rule.benchmark.name, + }, + { + ruleName: 'some lower case rule name', + findingsCount: '1', + complianceScore: '0%', + benchmarkName: data[3].rule.benchmark.name, + }, + { + ruleName: 'Another upper case rule name', + findingsCount: '1', + complianceScore: '100%', + benchmarkName: data[2].rule.benchmark.name, + }, + { + ruleName: 'lower case rule name', + findingsCount: '1', + complianceScore: '100%', + benchmarkName: data[1].rule.benchmark.name, + }, ]; - await asyncForEach(ruleNameOrder, async (resourceName, index) => { - const groupName = await grouping.getRowAtIndex(index); - expect(await groupName.getVisibleText()).to.be(resourceName); - }); + await asyncForEach( + ruleNameOrder, + async ({ ruleName, benchmarkName, findingsCount, complianceScore }, index) => { + const groupRow = await grouping.getRowAtIndex(index); + expect(await groupRow.getVisibleText()).to.contain(ruleName); + expect(await groupRow.getVisibleText()).to.contain(benchmarkName); + expect( + await ( + await groupRow.findByTestSubject('cloudSecurityFindingsComplianceScore') + ).getVisibleText() + ).to.be(complianceScore); + expect( + await (await groupRow.findByTestSubject('findings_grouping_counter')).getVisibleText() + ).to.be(findingsCount); + } + ); }); - it('groups findings by cloud account and sort case sensitive asc', async () => { + it('groups findings by cloud account and sort by compliance score desc', async () => { const groupSelector = await findings.groupSelector(); await groupSelector.setValue('Cloud account'); @@ -201,31 +271,98 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const unitCount = await grouping.getUnitCount(); expect(unitCount).to.be('4 findings'); - const cloudNameOrder = ['Account 1', 'Account 2', '—']; + const cloudNameOrder = [ + { + cloudName: 'Account 2', + findingsCount: '1', + complianceScore: '0%', + benchmarkName: data[3].rule.benchmark.name, + }, + { + cloudName: 'Account 1', + findingsCount: '1', + complianceScore: '100%', + benchmarkName: data[2].rule.benchmark.name, + }, + { + cloudName: 'No cloud account', + findingsCount: '2', + complianceScore: '50%', + benchmarkName: data[0].rule.benchmark.name, + }, + ]; - await asyncForEach(cloudNameOrder, async (resourceName, index) => { - const groupName = await grouping.getRowAtIndex(index); - expect(await groupName.getVisibleText()).to.be(resourceName); - }); + await asyncForEach( + cloudNameOrder, + async ({ cloudName, complianceScore, findingsCount, benchmarkName }, index) => { + const groupRow = await grouping.getRowAtIndex(index); + expect(await groupRow.getVisibleText()).to.contain(cloudName); + + if (cloudName !== 'No cloud account') { + expect(await groupRow.getVisibleText()).to.contain(benchmarkName); + } + + expect( + await ( + await groupRow.findByTestSubject('cloudSecurityFindingsComplianceScore') + ).getVisibleText() + ).to.be(complianceScore); + expect( + await (await groupRow.findByTestSubject('findings_grouping_counter')).getVisibleText() + ).to.be(findingsCount); + } + ); }); - it('groups findings by Kubernetes cluster and sort case sensitive asc', async () => { + it('groups findings by Kubernetes cluster and sort by compliance score desc', async () => { const groupSelector = await findings.groupSelector(); await groupSelector.setValue('Kubernetes cluster'); const grouping = await findings.findingsGrouping(); const groupCount = await grouping.getGroupCount(); - expect(groupCount).to.be('2 groups'); + expect(groupCount).to.be('3 groups'); const unitCount = await grouping.getUnitCount(); expect(unitCount).to.be('4 findings'); - const cloudNameOrder = ['Cluster 1', '—']; + const kubernetesOrder = [ + { + clusterName: 'Cluster 1', + findingsCount: '1', + complianceScore: '0%', + benchmarkName: data[0].rule.benchmark.name, + }, + { + clusterName: 'Cluster 2', + findingsCount: '1', + complianceScore: '100%', + benchmarkName: data[1].rule.benchmark.name, + }, + { + clusterName: 'No Kubernetes cluster', + findingsCount: '2', + complianceScore: '50%', + }, + ]; - await asyncForEach(cloudNameOrder, async (resourceName, index) => { - const groupName = await grouping.getRowAtIndex(index); - expect(await groupName.getVisibleText()).to.be(resourceName); - }); + await asyncForEach( + kubernetesOrder, + async ({ clusterName, complianceScore, findingsCount, benchmarkName }, index) => { + const groupRow = await grouping.getRowAtIndex(index); + expect(await groupRow.getVisibleText()).to.contain(clusterName); + if (clusterName !== 'No Kubernetes cluster') { + expect(await groupRow.getVisibleText()).to.contain(benchmarkName); + } + expect( + await ( + await groupRow.findByTestSubject('cloudSecurityFindingsComplianceScore') + ).getVisibleText() + ).to.be(complianceScore); + expect( + await (await groupRow.findByTestSubject('findings_grouping_counter')).getVisibleText() + ).to.be(findingsCount); + } + ); }); }); describe('SearchBar', () => { @@ -239,12 +376,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const grouping = await findings.findingsGrouping(); - const resourceOrder = ['kubelet']; - - await asyncForEach(resourceOrder, async (resourceName, index) => { - const groupName = await grouping.getRowAtIndex(index); - expect(await groupName.getVisibleText()).to.be(resourceName); - }); + const groupRow = await grouping.getRowAtIndex(0); + expect(await groupRow.getVisibleText()).to.contain(data[0].resource.name); const groupCount = await grouping.getGroupCount(); expect(groupCount).to.be('1 group'); @@ -272,12 +405,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const grouping = await findings.findingsGrouping(); - const resourceOrder = ['kubelet']; - - await asyncForEach(resourceOrder, async (resourceName, index) => { - const groupName = await grouping.getRowAtIndex(index); - expect(await groupName.getVisibleText()).to.be(resourceName); - }); + const groupRow = await grouping.getRowAtIndex(0); + expect(await groupRow.getVisibleText()).to.contain(data[0].resource.name); const groupCount = await grouping.getGroupCount(); expect(groupCount).to.be('1 group'); @@ -300,7 +429,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await (await firstRow.findByCssSelector('button')).click(); const latestFindingsTable = findings.createDataTableObject('latest_findings_table'); expect(await latestFindingsTable.getRowsCount()).to.be(1); - expect(await latestFindingsTable.hasColumnValue('rule.name', 'lower case rule name')).to.be( + expect(await latestFindingsTable.hasColumnValue('rule.name', data[0].rule.name)).to.be( true ); });