[Cloud Security] CNVM dashboard statistics (#158652)

This commit is contained in:
Jordan 2023-06-12 18:10:15 +03:00 committed by GitHub
parent e0b8304c8a
commit 015ae200e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 342 additions and 48 deletions

View file

@ -5,10 +5,13 @@
* 2.0.
*/
import { PostureTypes } from './types';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { PostureTypes, VulnSeverity } from './types';
export const STATUS_ROUTE_PATH = '/internal/cloud_security_posture/status';
export const STATS_ROUTE_PATH = '/internal/cloud_security_posture/stats/{policy_template}';
export const VULNERABILITIES_DASHBOARD_ROUTE_PATH =
'/internal/cloud_security_posture/vulnerabilities_dashboard';
export const BENCHMARKS_ROUTE_PATH = '/internal/cloud_security_posture/benchmarks';
export const FIND_CSP_RULE_TEMPLATE_ROUTE_PATH = '/internal/cloud_security_posture/rules/_find';
@ -102,3 +105,30 @@ export const POSTURE_TYPES: { [x: string]: PostureTypes } = {
export const VULNERABILITIES = 'vulnerabilities';
export const CONFIGURATIONS = 'configurations';
export const getSafeVulnerabilitiesQueryFilter = (query?: QueryDslQueryContainer) => ({
...query,
bool: {
...query?.bool,
filter: [
...((query?.bool?.filter as []) || []),
{ exists: { field: 'vulnerability.score.base' } },
{ exists: { field: 'vulnerability.score.version' } },
{ exists: { field: 'vulnerability.severity' } },
{ exists: { field: 'resource.name' } },
{ match_phrase: { 'vulnerability.enumeration': 'CVE' } },
],
must_not: [
...((query?.bool?.must_not as []) || []),
{ match_phrase: { 'vulnerability.severity': 'UNKNOWN' } },
],
},
});
export const SEVERITY: Record<VulnSeverity, VulnSeverity> = {
LOW: 'LOW',
MEDIUM: 'MEDIUM',
HIGH: 'HIGH',
CRITICAL: 'CRITICAL',
UNKNOWN: 'UNKNOWN',
};

View file

@ -125,3 +125,19 @@ export interface GetCspRuleTemplateResponse {
page: number;
perPage: number;
}
// CNVM DASHBOARD
export interface CnvmStatistics {
criticalCount: number | undefined;
highCount: number | undefined;
mediumCount: number | undefined;
resourcesScanned: number | undefined;
cloudRegions: number | undefined;
}
export interface CnvmDashboardData {
cnvmStatistics: CnvmStatistics;
}
export type VulnSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'UNKNOWN';

View file

@ -0,0 +1,24 @@
/*
* 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, UseQueryOptions } from '@tanstack/react-query';
import { CnvmDashboardData } from '../../../common/types';
import { useKibana } from '../hooks/use_kibana';
import { VULNERABILITIES_DASHBOARD_ROUTE_PATH } from '../../../common/constants';
const cnvmKey = 'use-cnvm-statistics-api-key';
export const useCnvmStatisticsApi = (
options?: UseQueryOptions<unknown, unknown, CnvmDashboardData, string[]>
) => {
const { http } = useKibana().services;
return useQuery(
[cnvmKey],
() => http.get<CnvmDashboardData>(VULNERABILITIES_DASHBOARD_ROUTE_PATH),
options
);
};

View file

@ -68,3 +68,6 @@ export const useNavigateFindings = () => useNavigate(findingsNavigation.findings
export const useNavigateFindingsByResource = () =>
useNavigate(findingsNavigation.findings_by_resource.path);
export const useNavigateVulnerabilities = () =>
useNavigate(findingsNavigation.vulnerabilities.path);

View file

@ -6,6 +6,7 @@
*/
import { euiThemeVars } from '@kbn/ui-theme';
import { VulnSeverity } from '../../../common/types';
export const getCvsScoreColor = (score: number): string | undefined => {
if (score <= 4) {
@ -19,7 +20,7 @@ export const getCvsScoreColor = (score: number): string | undefined => {
}
};
export const getSeverityStatusColor = (severity: string): string | undefined => {
export const getSeverityStatusColor = (severity: VulnSeverity): string => {
switch (severity) {
case 'LOW':
return euiThemeVars.euiColorVis0;

View file

@ -6,15 +6,26 @@
*/
import React from 'react';
import { EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const CompactFormattedNumber = ({
number = 0,
number,
abbreviateAbove = 999999,
}: {
number: number;
number?: number;
/** numbers higher than the value of this field will be abbreviated using compact notation and have a tooltip displaying the full value */
abbreviateAbove?: number;
}) => {
if (!number && number !== 0) {
return (
<span>
{i18n.translate('xpack.csp.compactFormattedNumber.naTitle', {
defaultMessage: 'N/A',
})}
</span>
);
}
if (number <= abbreviateAbove) {
return <span>{number.toLocaleString()}</span>;
}

View file

@ -18,17 +18,6 @@ export interface CspCounterCardProps {
description: EuiStatProps['description'];
}
// Todo: remove when EuiIcon type="pivot" is available
const PivotIcon = ({ ...props }) => (
<svg width="16" height="16" fill="none" viewBox="0 0 16 16" {...props}>
<path
fillRule="evenodd"
d="M2.89 13.847 11.239 5.5a.522.522 0 0 0-.737-.737L2.154 13.11a.522.522 0 0 0 .738.738ZM14 6.696a.522.522 0 1 1-1.043 0v-3.13a.522.522 0 0 0-.522-.523h-3.13a.522.522 0 1 1 0-1.043h3.13C13.299 2 14 2.7 14 3.565v3.13Z"
clipRule="evenodd"
/>
</svg>
);
export const CspCounterCard = (counter: CspCounterCardProps) => {
const { euiTheme } = useEuiTheme();
@ -68,8 +57,7 @@ export const CspCounterCard = (counter: CspCounterCardProps) => {
/>
{counter.onClick && (
<EuiIcon
// Todo: update when EuiIcon type="pivot" is available
type={PivotIcon}
type={'pivot'}
css={css`
color: ${euiTheme.colors.lightShade};
position: absolute;

View file

@ -10,6 +10,7 @@ import React from 'react';
import { css } from '@emotion/react';
import { float } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { getCvsScoreColor, getSeverityStatusColor } from '../common/utils/get_vulnerability_colors';
import { VulnSeverity } from '../../common/types';
interface CVSScoreBadgeProps {
score: float;
@ -17,7 +18,7 @@ interface CVSScoreBadgeProps {
}
interface SeverityStatusBadgeProps {
status: string;
severity: VulnSeverity;
}
export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => {
@ -53,8 +54,8 @@ export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => {
);
};
export const SeverityStatusBadge = ({ status }: SeverityStatusBadgeProps) => {
const color = getSeverityStatusColor(status);
export const SeverityStatusBadge = ({ severity }: SeverityStatusBadgeProps) => {
const color = getSeverityStatusColor(severity);
return (
<div
@ -68,10 +69,10 @@ export const SeverityStatusBadge = ({ status }: SeverityStatusBadgeProps) => {
type="dot"
color={color}
css={css`
opacity: ${status ? 1 : 0};
opacity: ${severity ? 1 : 0};
`}
/>
{status}
{severity}
</div>
);
};

View file

@ -58,9 +58,9 @@ describe('<CloudSummarySection />', () => {
expect(screen.getByTestId(DASHBOARD_COUNTER_CARDS.FAILING_FINDINGS)).toHaveTextContent('1M');
});
it('renders 0 as empty state', () => {
it('renders N/A as an empty state', () => {
renderCloudSummarySection({ stats: { totalFailed: undefined } });
expect(screen.getByTestId(DASHBOARD_COUNTER_CARDS.FAILING_FINDINGS)).toHaveTextContent('0');
expect(screen.getByTestId(DASHBOARD_COUNTER_CARDS.FAILING_FINDINGS)).toHaveTextContent('N/A');
});
});

View file

@ -9,7 +9,10 @@ import { lastValueFrom } from 'rxjs';
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
import { number } from 'io-ts';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../../common/constants';
import {
getSafeVulnerabilitiesQueryFilter,
LATEST_VULNERABILITIES_INDEX_PATTERN,
} from '../../../../common/constants';
import { useKibana } from '../../../common/hooks/use_kibana';
import { showErrorToast } from '../../../common/utils/show_error_toast';
import { FindingsBaseEsQuery } from '../../../common/types';
@ -29,24 +32,7 @@ interface VulnerabilitiesQuery extends FindingsBaseEsQuery {
export const getFindingsQuery = ({ query, sort, pageIndex, pageSize }: VulnerabilitiesQuery) => ({
index: LATEST_VULNERABILITIES_INDEX_PATTERN,
query: {
...query,
bool: {
...query?.bool,
filter: [
...(query?.bool?.filter || []),
{ exists: { field: 'vulnerability.score.base' } },
{ exists: { field: 'vulnerability.score.version' } },
{ exists: { field: 'vulnerability.severity' } },
{ exists: { field: 'resource.name' } },
{ match_phrase: { 'vulnerability.enumeration': 'CVE' } },
],
must_not: [
...(query?.bool?.must_not || []),
{ match_phrase: { 'vulnerability.severity': 'UNKNOWN' } },
],
},
},
query: getSafeVulnerabilitiesQueryFilter(query),
from: pageIndex * pageSize,
size: pageSize,
sort,

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { VulnSeverity } from '../../../common/types';
export interface VulnerabilityRecord {
'@timestamp': string;
resource?: {
@ -86,7 +88,7 @@ export interface Vulnerability {
id: string;
title: string;
reference: string;
severity: string;
severity: VulnSeverity;
cvss: {
nvd: VectorScoreBase;
redhat?: VectorScoreBase;

View file

@ -364,7 +364,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
if (!vulnerabilityRow.vulnerability.severity) {
return null;
}
return <SeverityStatusBadge status={vulnerabilityRow.vulnerability.severity} />;
return <SeverityStatusBadge severity={vulnerabilityRow.vulnerability.severity} />;
}
if (columnId === vulnerabilitiesColumns.package) {

View file

@ -166,7 +166,7 @@ export const VulnerabilityFindingFlyout = ({
`}
>
<EuiFlexItem>
<SeverityStatusBadge status={vulnerability?.severity} />
<SeverityStatusBadge severity={vulnerability?.severity} />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup

View file

@ -5,14 +5,15 @@
* 2.0.
*/
import React from 'react';
import { EuiPageHeader } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiPageHeader, EuiSpacer } from '@elastic/eui';
import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api';
import { NoVulnerabilitiesStates } from '../../components/no_vulnerabilities_states';
import {
VULNERABILITY_DASHBOARD_CONTAINER,
VULNERABILITY_DASHBOARD_PAGE_HEADER,
} from '../compliance_dashboard/test_subjects';
import { VulnerabilityStatistics } from './vulnerability_statistics';
import { CloudPosturePageTitle } from '../../components/cloud_posture_page_title';
import { CloudPosturePage } from '../../components/cloud_posture_page';
@ -36,7 +37,10 @@ export const VulnerabilityDashboard = () => {
{getSetupStatus?.data?.vuln_mgmt?.status !== 'indexed' ? (
<NoVulnerabilitiesStates />
) : (
<div data-test-subj={VULNERABILITY_DASHBOARD_CONTAINER}>{/* temporarily empty */}</div>
<div data-test-subj={VULNERABILITY_DASHBOARD_CONTAINER}>
<EuiSpacer />
<VulnerabilityStatistics />
</div>
)}
</CloudPosturePage>
);

View file

@ -0,0 +1,94 @@
/*
* 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, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SEVERITY } from '../../../common/constants';
import { useCnvmStatisticsApi } from '../../common/api/use_vulnerabilities_stats_api';
import { useNavigateVulnerabilities } from '../../common/hooks/use_navigate_findings';
import { CompactFormattedNumber } from '../../components/compact_formatted_number';
import { getSeverityStatusColor } from '../../common/utils/get_vulnerability_colors';
import { CspCounterCard } from '../../components/csp_counter_card';
export const VulnerabilityStatistics = () => {
const navToVulnerabilities = useNavigateVulnerabilities();
const getCnvmStats = useCnvmStatisticsApi();
const stats = useMemo(
() => [
{
id: 'cloud-regions-stat',
title: <CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.cloudRegions} />,
description: i18n.translate('xpack.csp.cnvmDashboard.statistics.cloudRegionTitle', {
defaultMessage: 'Cloud Regions',
}),
},
{
id: 'assets-scanned-stat',
title: (
<CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.resourcesScanned} />
),
description: i18n.translate('xpack.csp.cnvmDashboard.statistics.resourcesScannedTitle', {
defaultMessage: 'Resources Scanned',
}),
},
{
id: 'critical-count-stat',
title: <CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.criticalCount} />,
description: (
<EuiHealth color={getSeverityStatusColor(SEVERITY.CRITICAL)}>
{i18n.translate('xpack.csp.cnvmDashboard.statistics.criticalTitle', {
defaultMessage: 'Critical',
})}
</EuiHealth>
),
onClick: () => {
navToVulnerabilities({ 'vulnerability.severity': SEVERITY.CRITICAL });
},
},
{
id: 'high-count-stat',
title: <CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.highCount} />,
description: (
<EuiHealth color={getSeverityStatusColor(SEVERITY.HIGH)}>
{i18n.translate('xpack.csp.cnvmDashboard.statistics.highTitle', {
defaultMessage: 'High',
})}
</EuiHealth>
),
onClick: () => {
navToVulnerabilities({ 'vulnerability.severity': SEVERITY.HIGH });
},
},
{
id: 'medium-count-stat',
title: <CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.mediumCount} />,
description: (
<EuiHealth color={getSeverityStatusColor(SEVERITY.MEDIUM)}>
{i18n.translate('xpack.csp.cnvmDashboard.statistics.mediumTitle', {
defaultMessage: 'Medium',
})}
</EuiHealth>
),
onClick: () => {
navToVulnerabilities({ 'vulnerability.severity': SEVERITY.MEDIUM });
},
},
],
[getCnvmStats.data, navToVulnerabilities]
);
return (
<EuiFlexGroup>
{stats.map((stat) => (
<EuiFlexItem>
<CspCounterCard {...stat} />
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};

View file

@ -14,6 +14,7 @@ import type {
} from '../types';
import { PLUGIN_ID } from '../../common';
import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compliance_dashboard';
import { defineGetVulnerabilitiesDashboardRoute } from './vulnerabilities_dashboard/vulnerabilities_dashboard';
import { defineGetBenchmarksRoute } from './benchmarks/benchmarks';
import { defineGetCspStatusRoute } from './status/status';
import { defineFindCspRuleTemplateRoute } from './csp_rule_template/get_csp_rule_template';
@ -33,6 +34,7 @@ export function setupRoutes({
}) {
const router = core.http.createRouter<CspRequestHandlerContext>();
defineGetComplianceDashboardRoute(router);
defineGetVulnerabilitiesDashboardRoute(router);
defineGetBenchmarksRoute(router);
defineGetCspStatusRoute(router);
defineFindCspRuleTemplateRoute(router);

View file

@ -0,0 +1,74 @@
/*
* 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 { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { LATEST_VULNERABILITIES_INDEX_DEFAULT_NS } from '../../../common/constants';
export interface VulnerabilitiesStatisticsQueryResult {
critical: {
doc_count: number;
};
high: {
doc_count: number;
};
medium: {
doc_count: number;
};
resources_scanned: {
value: number;
};
cloud_regions: {
value: number;
};
}
export const getVulnerabilitiesStatisticsQuery = (
query: QueryDslQueryContainer
): SearchRequest => ({
size: 0,
query,
index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS,
aggs: {
critical: {
filter: { term: { 'vulnerability.severity': 'CRITICAL' } },
},
high: {
filter: { term: { 'vulnerability.severity': 'HIGH' } },
},
medium: {
filter: { term: { 'vulnerability.severity': 'MEDIUM' } },
},
resources_scanned: {
cardinality: {
field: 'resource.id',
},
},
cloud_regions: {
cardinality: {
field: 'cloud.region',
},
},
},
});
export const getVulnerabilitiesStatistics = async (
esClient: ElasticsearchClient,
query: QueryDslQueryContainer
) => {
const queryResult = await esClient.search<unknown, VulnerabilitiesStatisticsQueryResult>(
getVulnerabilitiesStatisticsQuery(query)
);
return {
criticalCount: queryResult.aggregations?.critical.doc_count,
highCount: queryResult.aggregations?.high.doc_count,
mediumCount: queryResult.aggregations?.medium.doc_count,
resourcesScanned: queryResult.aggregations?.resources_scanned.value,
cloudRegions: queryResult.aggregations?.cloud_regions.value,
};
};

View file

@ -0,0 +1,58 @@
/*
* 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 { transformError } from '@kbn/securitysolution-es-utils';
import type { CnvmDashboardData } from '../../../common/types';
import {
VULNERABILITIES_DASHBOARD_ROUTE_PATH,
getSafeVulnerabilitiesQueryFilter,
} from '../../../common/constants';
import { CspRouter } from '../../types';
import { getVulnerabilitiesStatistics } from './get_vulnerabilities_statistics';
export interface KeyDocCount<TKey = string> {
key: TKey;
doc_count: number;
}
export const defineGetVulnerabilitiesDashboardRoute = (router: CspRouter): void =>
router.get(
{
path: VULNERABILITIES_DASHBOARD_ROUTE_PATH,
validate: false,
options: {
tags: ['access:cloud-security-posture-read'],
},
},
async (context, request, response) => {
const cspContext = await context.csp;
try {
const esClient = cspContext.esClient.asCurrentUser;
const query = getSafeVulnerabilitiesQueryFilter();
const [cnvmStatistics] = await Promise.all([getVulnerabilitiesStatistics(esClient, query)]);
const body: CnvmDashboardData = {
cnvmStatistics,
};
return response.ok({
body,
});
} catch (err) {
const error = transformError(err);
cspContext.logger.error(`Error while fetching Vulnerabilities stats: ${err}`);
return response.customError({
body: { message: error.message },
statusCode: error.statusCode,
});
}
}
);