mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Cloud Security][CNVM] Vulnerabilities Grouped by Resource page (#158987)
This commit is contained in:
parent
723d120c1f
commit
a549d52c21
25 changed files with 1080 additions and 88 deletions
|
@ -5,7 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { PostureTypes, VulnSeverity } from './types';
|
||||
|
||||
export const STATUS_ROUTE_PATH = '/internal/cloud_security_posture/status';
|
||||
|
@ -61,7 +60,6 @@ export const POSTURE_TYPE_ALL = 'all';
|
|||
export const INTERNAL_FEATURE_FLAGS = {
|
||||
showManageRulesMock: false,
|
||||
showFindingFlyoutEvidence: false,
|
||||
showFindingsGroupBy: true,
|
||||
} as const;
|
||||
|
||||
export const CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE = 'csp-rule-template';
|
||||
|
@ -106,29 +104,12 @@ 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> = {
|
||||
export const VULNERABILITIES_SEVERITY: Record<VulnSeverity, VulnSeverity> = {
|
||||
LOW: 'LOW',
|
||||
MEDIUM: 'MEDIUM',
|
||||
HIGH: 'HIGH',
|
||||
CRITICAL: 'CRITICAL',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
};
|
||||
|
||||
export const VULNERABILITIES_ENUMERATION = 'CVE';
|
||||
|
|
|
@ -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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { VULNERABILITIES_ENUMERATION, VULNERABILITIES_SEVERITY } from '../constants';
|
||||
|
||||
export const getSafeVulnerabilitiesQueryFilter = (query?: QueryDslQueryContainer) => ({
|
||||
...query,
|
||||
bool: {
|
||||
...query?.bool,
|
||||
filter: [
|
||||
...((query?.bool?.filter as []) || []),
|
||||
{
|
||||
bool: {
|
||||
minimum_should_match: 1,
|
||||
should: [
|
||||
{ match_phrase: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.CRITICAL } },
|
||||
{ match_phrase: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.HIGH } },
|
||||
{ match_phrase: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.MEDIUM } },
|
||||
{ match_phrase: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.LOW } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ exists: { field: 'vulnerability.score.base' } },
|
||||
{ exists: { field: 'vulnerability.score.version' } },
|
||||
{ exists: { field: 'vulnerability.severity' } },
|
||||
{ exists: { field: 'resource.id' } },
|
||||
{ exists: { field: 'resource.name' } },
|
||||
{ match_phrase: { 'vulnerability.enumeration': VULNERABILITIES_ENUMERATION } },
|
||||
],
|
||||
},
|
||||
});
|
|
@ -89,6 +89,16 @@ export const findingsNavigation = {
|
|||
path: `${CLOUD_SECURITY_POSTURE_BASE_PATH}/findings/vulnerabilities`,
|
||||
id: 'cloud_security_posture-findings-vulnerabilities',
|
||||
},
|
||||
vulnerabilities_by_resource: {
|
||||
name: NAV_ITEMS_NAMES.FINDINGS,
|
||||
path: `${CLOUD_SECURITY_POSTURE_BASE_PATH}/findings/vulnerabilities/resource`,
|
||||
id: 'cloud_security_posture-findings-vulnerabilities-resource',
|
||||
},
|
||||
resource_vulnerabilities: {
|
||||
name: NAV_ITEMS_NAMES.FINDINGS,
|
||||
path: `${CLOUD_SECURITY_POSTURE_BASE_PATH}/findings/vulnerabilities/resource/:resourceId`,
|
||||
id: 'cloud_security_posture-findings-vulnerabilities-resourceId',
|
||||
},
|
||||
};
|
||||
|
||||
const ELASTIC_BASE_SHORT_URL = 'https://ela.st';
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import { VULNERABILITIES_SEVERITY } from '../../../common/constants';
|
||||
import { VulnSeverity } from '../../../common/types';
|
||||
|
||||
export const getCvsScoreColor = (score: number): string | undefined => {
|
||||
|
@ -22,13 +23,13 @@ export const getCvsScoreColor = (score: number): string | undefined => {
|
|||
|
||||
export const getSeverityStatusColor = (severity: VulnSeverity): string => {
|
||||
switch (severity) {
|
||||
case 'LOW':
|
||||
case VULNERABILITIES_SEVERITY.LOW:
|
||||
return euiThemeVars.euiColorVis0;
|
||||
case 'MEDIUM':
|
||||
return euiThemeVars.euiColorVis7;
|
||||
case 'HIGH':
|
||||
return euiThemeVars.euiColorVis9;
|
||||
case 'CRITICAL':
|
||||
case VULNERABILITIES_SEVERITY.MEDIUM:
|
||||
return euiThemeVars.euiColorVis5_behindText;
|
||||
case VULNERABILITIES_SEVERITY.HIGH:
|
||||
return euiThemeVars.euiColorVis9_behindText;
|
||||
case VULNERABILITIES_SEVERITY.CRITICAL:
|
||||
return euiThemeVars.euiColorDanger;
|
||||
default:
|
||||
return '#aaa';
|
||||
|
|
|
@ -36,11 +36,11 @@ describe('getSeverityStatusColor', () => {
|
|||
});
|
||||
|
||||
it('should return the correct color for MEDIUM severity', () => {
|
||||
expect(getSeverityStatusColor('MEDIUM')).toBe(euiThemeVars.euiColorVis7);
|
||||
expect(getSeverityStatusColor('MEDIUM')).toBe(euiThemeVars.euiColorVis5_behindText);
|
||||
});
|
||||
|
||||
it('should return the correct color for HIGH severity', () => {
|
||||
expect(getSeverityStatusColor('HIGH')).toBe(euiThemeVars.euiColorVis9);
|
||||
expect(getSeverityStatusColor('HIGH')).toBe(euiThemeVars.euiColorVis9_behindText);
|
||||
});
|
||||
|
||||
it('should return the correct color for CRITICAL severity', () => {
|
||||
|
|
|
@ -140,11 +140,14 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
|
|||
loading={findingsGroupByNone.isFetching}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false} style={{ width: 400 }}>
|
||||
{!error && <FindingsGroupBySelector type="default" />}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{!error && (
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false} style={{ width: 188 }}>
|
||||
<FindingsGroupBySelector type="default" />
|
||||
<EuiSpacer size="m" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{error && <ErrorCallout error={error} />}
|
||||
{!error && (
|
||||
<>
|
||||
|
|
|
@ -112,11 +112,14 @@ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => {
|
|||
loading={findingsGroupByResource.isFetching}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false} style={{ width: 400 }}>
|
||||
{!error && <FindingsGroupBySelector type="resource" />}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{!error && (
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false} style={{ width: 188 }}>
|
||||
<FindingsGroupBySelector type="resource" />
|
||||
<EuiSpacer size="m" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{error && <ErrorCallout error={error} />}
|
||||
{!error && (
|
||||
<>
|
||||
|
|
|
@ -5,11 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiComboBox, EuiFormLabel, EuiSpacer, type EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { EuiComboBox, EuiFormLabel, type EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants';
|
||||
import type { FindingsGroupByKind } from '../../../common/types';
|
||||
import { findingsNavigation } from '../../../common/navigation/constants';
|
||||
import * as TEST_SUBJECTS from '../test_subjects';
|
||||
|
@ -31,6 +30,7 @@ const getGroupByOptions = (): Array<EuiComboBoxOptionOption<FindingsGroupByKind>
|
|||
|
||||
interface Props {
|
||||
type: FindingsGroupByKind;
|
||||
pathnameHandler?: (opts: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>) => string;
|
||||
}
|
||||
|
||||
const getFindingsGroupPath = (opts: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>) => {
|
||||
|
@ -45,27 +45,27 @@ const getFindingsGroupPath = (opts: Array<EuiComboBoxOptionOption<FindingsGroupB
|
|||
}
|
||||
};
|
||||
|
||||
export const FindingsGroupBySelector = ({ type }: Props) => {
|
||||
export const FindingsGroupBySelector = ({
|
||||
type,
|
||||
pathnameHandler = getFindingsGroupPath,
|
||||
}: Props) => {
|
||||
const groupByOptions = useMemo(getGroupByOptions, []);
|
||||
const history = useHistory();
|
||||
|
||||
if (!INTERNAL_FEATURE_FLAGS.showFindingsGroupBy) return null;
|
||||
|
||||
const onChange = (options: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>) =>
|
||||
history.push({ pathname: getFindingsGroupPath(options) });
|
||||
history.push({ pathname: pathnameHandler(options) });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EuiComboBox
|
||||
data-test-subj={TEST_SUBJECTS.FINDINGS_GROUP_BY_SELECTOR}
|
||||
prepend={<GroupByLabel />}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={groupByOptions}
|
||||
selectedOptions={groupByOptions.filter((o) => o.value === type)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</div>
|
||||
<EuiComboBox
|
||||
data-test-subj={TEST_SUBJECTS.FINDINGS_GROUP_BY_SELECTOR}
|
||||
prepend={<GroupByLabel />}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={groupByOptions}
|
||||
selectedOptions={groupByOptions.filter((o) => o.value === type)}
|
||||
onChange={onChange}
|
||||
isClearable={false}
|
||||
compressed
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -32,6 +32,15 @@ export const Findings = () => {
|
|||
const navigateToConfigurationsTab = () => {
|
||||
history.push({ pathname: findingsNavigation.findings_default.path });
|
||||
};
|
||||
|
||||
const isVulnerabilitiesTabSelected = (pathname: string) => {
|
||||
return (
|
||||
pathname === findingsNavigation.vulnerabilities.path ||
|
||||
pathname === findingsNavigation.vulnerabilities_by_resource.path ||
|
||||
pathname === findingsNavigation.resource_vulnerabilities.path
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiTitle size="l">
|
||||
|
@ -44,7 +53,7 @@ export const Findings = () => {
|
|||
<EuiTab
|
||||
key="vuln_mgmt"
|
||||
onClick={navigateToVulnerabilitiesTab}
|
||||
isSelected={location.pathname === findingsNavigation.vulnerabilities.path}
|
||||
isSelected={isVulnerabilitiesTabSelected(location.pathname)}
|
||||
>
|
||||
<EuiFlexGroup responsive={false} alignItems="center" direction="row" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
|
@ -73,7 +82,7 @@ export const Findings = () => {
|
|||
<EuiTab
|
||||
key="configurations"
|
||||
onClick={navigateToConfigurationsTab}
|
||||
isSelected={location.pathname !== findingsNavigation.vulnerabilities.path}
|
||||
isSelected={!isVulnerabilitiesTabSelected(location.pathname)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.csp.findings.tabs.misconfigurations"
|
||||
|
@ -94,10 +103,14 @@ export const Findings = () => {
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Route path={findingsNavigation.findings_default.path} component={Configurations} />
|
||||
<Route path={findingsNavigation.vulnerabilities.path} component={Vulnerabilities} />
|
||||
<Route path={findingsNavigation.findings_by_resource.path} component={Configurations} />
|
||||
<Route path={findingsNavigation.vulnerabilities.path} component={Vulnerabilities} />
|
||||
<Route
|
||||
path={findingsNavigation.vulnerabilities_by_resource.path}
|
||||
component={Vulnerabilities}
|
||||
/>
|
||||
{/* Redirect to default findings page if no match */}
|
||||
<Route path="*" render={() => <Redirect to={findingsNavigation.findings_default.path} />} />
|
||||
</Switch>
|
||||
</>
|
||||
|
|
|
@ -8,23 +8,27 @@ import { useQuery } from '@tanstack/react-query';
|
|||
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 {
|
||||
getSafeVulnerabilitiesQueryFilter,
|
||||
LATEST_VULNERABILITIES_INDEX_PATTERN,
|
||||
} from '../../../../common/constants';
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
AggregationsMultiBucketAggregateBase,
|
||||
AggregationsStringRareTermsBucketKeys,
|
||||
Sort,
|
||||
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../../common/constants';
|
||||
import { getSafeVulnerabilitiesQueryFilter } from '../../../../common/utils/get_safe_vulnerabilities_query_filter';
|
||||
import { useKibana } from '../../../common/hooks/use_kibana';
|
||||
import { showErrorToast } from '../../../common/utils/show_error_toast';
|
||||
import { FindingsBaseEsQuery } from '../../../common/types';
|
||||
type LatestFindingsRequest = IKibanaSearchRequest<estypes.SearchRequest>;
|
||||
type LatestFindingsResponse = IKibanaSearchResponse<estypes.SearchResponse<any, FindingsAggs>>;
|
||||
type LatestFindingsRequest = IKibanaSearchRequest<SearchRequest>;
|
||||
type LatestFindingsResponse = IKibanaSearchResponse<SearchResponse<any, FindingsAggs>>;
|
||||
|
||||
interface FindingsAggs {
|
||||
count: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
|
||||
count: AggregationsMultiBucketAggregateBase<AggregationsStringRareTermsBucketKeys>;
|
||||
}
|
||||
|
||||
interface VulnerabilitiesQuery extends FindingsBaseEsQuery {
|
||||
sort: estypes.Sort;
|
||||
sort: Sort;
|
||||
enabled: boolean;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
|
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* 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 '@tanstack/react-query';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
|
||||
import {
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
AggregationsCardinalityAggregate,
|
||||
AggregationsMultiBucketAggregateBase,
|
||||
AggregationsSingleBucketAggregateBase,
|
||||
AggregationsStringRareTermsBucketKeys,
|
||||
AggregationsStringTermsBucketKeys,
|
||||
SortOrder,
|
||||
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import {
|
||||
LATEST_VULNERABILITIES_INDEX_PATTERN,
|
||||
VULNERABILITIES_SEVERITY,
|
||||
} from '../../../../common/constants';
|
||||
import { getSafeVulnerabilitiesQueryFilter } from '../../../../common/utils/get_safe_vulnerabilities_query_filter';
|
||||
|
||||
import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants';
|
||||
import { useKibana } from '../../../common/hooks/use_kibana';
|
||||
import { showErrorToast } from '../../../common/utils/show_error_toast';
|
||||
import { FindingsBaseEsQuery } from '../../../common/types';
|
||||
|
||||
type LatestFindingsRequest = IKibanaSearchRequest<SearchRequest>;
|
||||
type LatestFindingsResponse = IKibanaSearchResponse<SearchResponse<any, VulnerabilitiesAggs>>;
|
||||
|
||||
interface VulnerabilitiesAggs {
|
||||
count: AggregationsMultiBucketAggregateBase<AggregationsStringRareTermsBucketKeys>;
|
||||
total: AggregationsCardinalityAggregate;
|
||||
resources: AggregationsMultiBucketAggregateBase<FindingsAggBucket>;
|
||||
}
|
||||
|
||||
interface FindingsAggBucket extends AggregationsStringRareTermsBucketKeys {
|
||||
name: AggregationsMultiBucketAggregateBase<AggregationsStringTermsBucketKeys>;
|
||||
region: AggregationsMultiBucketAggregateBase<AggregationsStringTermsBucketKeys>;
|
||||
critical: AggregationsSingleBucketAggregateBase;
|
||||
high: AggregationsSingleBucketAggregateBase;
|
||||
medium: AggregationsSingleBucketAggregateBase;
|
||||
low: AggregationsSingleBucketAggregateBase;
|
||||
}
|
||||
|
||||
interface VulnerabilitiesQuery extends FindingsBaseEsQuery {
|
||||
sortOrder: SortOrder;
|
||||
enabled: boolean;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const getQuery = ({
|
||||
query,
|
||||
sortOrder = 'desc',
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: VulnerabilitiesQuery) => ({
|
||||
index: LATEST_VULNERABILITIES_INDEX_PATTERN,
|
||||
query: getSafeVulnerabilitiesQueryFilter(query),
|
||||
aggs: {
|
||||
total: { cardinality: { field: 'resource.id' } },
|
||||
resources: {
|
||||
terms: {
|
||||
field: 'resource.id',
|
||||
size: MAX_FINDINGS_TO_LOAD * 3,
|
||||
// in case there are more resources then size, ensuring resources with more vulnerabilities
|
||||
// will be included first, and then vulnerabilities with critical and high severity
|
||||
order: [{ _count: sortOrder }, { critical: 'desc' }, { high: 'desc' }, { medium: 'desc' }],
|
||||
},
|
||||
aggs: {
|
||||
vulnerabilitiesCountBucketSort: {
|
||||
bucket_sort: {
|
||||
sort: [{ _count: { order: sortOrder } }],
|
||||
from: pageIndex * pageSize,
|
||||
size: pageSize,
|
||||
},
|
||||
},
|
||||
name: {
|
||||
terms: { field: 'resource.name', size: 1 },
|
||||
},
|
||||
region: {
|
||||
terms: { field: 'cloud.region', size: 1 },
|
||||
},
|
||||
critical: {
|
||||
filter: {
|
||||
term: {
|
||||
'vulnerability.severity': { value: VULNERABILITIES_SEVERITY.CRITICAL },
|
||||
},
|
||||
},
|
||||
},
|
||||
high: {
|
||||
filter: {
|
||||
term: {
|
||||
'vulnerability.severity': { value: VULNERABILITIES_SEVERITY.HIGH },
|
||||
},
|
||||
},
|
||||
},
|
||||
medium: {
|
||||
filter: {
|
||||
term: {
|
||||
'vulnerability.severity': { value: VULNERABILITIES_SEVERITY.MEDIUM },
|
||||
},
|
||||
},
|
||||
},
|
||||
low: {
|
||||
filter: {
|
||||
term: { 'vulnerability.severity': { value: VULNERABILITIES_SEVERITY.LOW } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
size: 0,
|
||||
});
|
||||
const getFirstKey = (
|
||||
buckets: AggregationsMultiBucketAggregateBase<AggregationsStringTermsBucketKeys>['buckets']
|
||||
): undefined | string => {
|
||||
if (!!Array.isArray(buckets) && !!buckets.length) return buckets[0].key;
|
||||
};
|
||||
const createVulnerabilitiesByResource = (resource: FindingsAggBucket) => ({
|
||||
'resource.id': resource.key,
|
||||
'resource.name': getFirstKey(resource.name.buckets),
|
||||
'cloud.region': getFirstKey(resource.region.buckets),
|
||||
vulnerabilities_count: resource.doc_count,
|
||||
severity_map: {
|
||||
critical: resource.critical.doc_count,
|
||||
high: resource.high.doc_count,
|
||||
medium: resource.medium.doc_count,
|
||||
low: resource.low.doc_count,
|
||||
},
|
||||
});
|
||||
|
||||
export const useLatestVulnerabilitiesByResource = (options: VulnerabilitiesQuery) => {
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
return useQuery(
|
||||
[LATEST_VULNERABILITIES_INDEX_PATTERN, 'resource', options],
|
||||
async () => {
|
||||
const {
|
||||
rawResponse: { hits, aggregations },
|
||||
} = await lastValueFrom(
|
||||
data.search.search<LatestFindingsRequest, LatestFindingsResponse>({
|
||||
params: getQuery(options),
|
||||
})
|
||||
);
|
||||
|
||||
if (!aggregations) throw new Error('Failed to aggregate by resource');
|
||||
|
||||
if (!Array.isArray(aggregations.resources.buckets))
|
||||
throw new Error('Failed to group by, missing resource id');
|
||||
|
||||
return {
|
||||
page: aggregations.resources.buckets.map(createVulnerabilitiesByResource),
|
||||
total: aggregations.total.value,
|
||||
total_vulnerabilities: hits.total as number,
|
||||
};
|
||||
},
|
||||
{
|
||||
staleTime: 5000,
|
||||
keepPreviousData: true,
|
||||
enabled: options.enabled,
|
||||
onError: (err: Error) => showErrorToast(toasts, err),
|
||||
}
|
||||
);
|
||||
};
|
|
@ -24,6 +24,11 @@ export const useStyles = () => {
|
|||
}
|
||||
& .euiDataGrid__controls {
|
||||
border-bottom: none;
|
||||
margin-bottom: ${euiTheme.size.s};
|
||||
|
||||
& .euiButtonEmpty {
|
||||
font-weight: ${euiTheme.font.weight.bold};
|
||||
}
|
||||
}
|
||||
& .euiButtonIcon {
|
||||
color: ${euiTheme.colors.primary};
|
||||
|
@ -37,6 +42,9 @@ export const useStyles = () => {
|
|||
& .euiDataGridRowCell__expandFlex {
|
||||
align-items: center;
|
||||
}
|
||||
& .euiDataGridRowCell.euiDataGridRowCell--numeric {
|
||||
text-align: left;
|
||||
}
|
||||
`;
|
||||
|
||||
const highlightStyle = css`
|
||||
|
@ -46,8 +54,13 @@ export const useStyles = () => {
|
|||
}
|
||||
`;
|
||||
|
||||
const groupBySelector = css`
|
||||
width: 188px;
|
||||
`;
|
||||
|
||||
return {
|
||||
highlightStyle,
|
||||
gridStyle,
|
||||
groupBySelector,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { findingsNavigation } from '../../../common/navigation/constants';
|
||||
import { FindingsGroupByKind } from '../../../common/types';
|
||||
|
||||
export const vulnerabilitiesPathnameHandler = (
|
||||
opts: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>
|
||||
) => {
|
||||
const [firstOption] = opts;
|
||||
|
||||
switch (firstOption?.value) {
|
||||
case 'resource':
|
||||
return findingsNavigation.vulnerabilities_by_resource.path;
|
||||
case 'default':
|
||||
default:
|
||||
return findingsNavigation.vulnerabilities.path;
|
||||
}
|
||||
};
|
|
@ -10,6 +10,7 @@ import {
|
|||
EuiDataGrid,
|
||||
EuiDataGridCellValueElementProps,
|
||||
EuiDataGridColumnCellAction,
|
||||
EuiFlexItem,
|
||||
EuiProgress,
|
||||
EuiSpacer,
|
||||
EuiToolTip,
|
||||
|
@ -19,6 +20,8 @@ import { cx } from '@emotion/css';
|
|||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import React, { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Switch } from 'react-router-dom';
|
||||
import { Route } from '@kbn/shared-ux-router';
|
||||
import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../common/constants';
|
||||
import { useCloudPostureTable } from '../../common/hooks/use_cloud_posture_table';
|
||||
import { useLatestVulnerabilities } from './hooks/use_latest_vulnerabilities';
|
||||
|
@ -47,6 +50,10 @@ import {
|
|||
getCaseInsensitiveSortScript,
|
||||
} from './utils/custom_sort_script';
|
||||
import { useStyles } from './hooks/use_styles';
|
||||
import { FindingsGroupBySelector } from '../configurations/layout/findings_group_by_selector';
|
||||
import { vulnerabilitiesPathnameHandler } from './utils/vulnerabilities_pathname_handler';
|
||||
import { findingsNavigation } from '../../common/navigation/constants';
|
||||
import { VulnerabilitiesByResource } from './vulnerabilities_by_resource/vulnerabilities_by_resource';
|
||||
|
||||
const getDefaultQuery = ({ query, filters }: any): any => ({
|
||||
query,
|
||||
|
@ -75,7 +82,19 @@ export const Vulnerabilities = () => {
|
|||
return defaultNoDataRenderer();
|
||||
}
|
||||
|
||||
return <VulnerabilitiesContent dataView={data} />;
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={findingsNavigation.vulnerabilities_by_resource.path}
|
||||
render={() => <VulnerabilitiesByResource dataView={data} />}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.vulnerabilities.path}
|
||||
render={() => <VulnerabilitiesContent dataView={data} />}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
|
||||
|
@ -431,7 +450,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
|
|||
loading={isLoading}
|
||||
placeholder={SEARCH_BAR_PLACEHOLDER}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiSpacer size="m" />
|
||||
{!isLoading && data.page.length === 0 ? (
|
||||
<EmptyState onResetFilters={onResetFilters} />
|
||||
) : (
|
||||
|
@ -457,6 +476,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
|
|||
showColumnSelector: false,
|
||||
showDisplaySelector: false,
|
||||
showKeyboardShortcuts: false,
|
||||
showFullScreenSelector: false,
|
||||
additionalControls: {
|
||||
left: {
|
||||
prepend: (
|
||||
|
@ -471,6 +491,14 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
|
|||
</>
|
||||
),
|
||||
},
|
||||
right: (
|
||||
<EuiFlexItem grow={false} className={styles.groupBySelector}>
|
||||
<FindingsGroupBySelector
|
||||
type="default"
|
||||
pathnameHandler={vulnerabilitiesPathnameHandler}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
),
|
||||
},
|
||||
}}
|
||||
gridStyle={{
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const getVulnerabilitiesByResourceData = () => ({
|
||||
total: 2,
|
||||
total_vulnerabilities: 8,
|
||||
page: [
|
||||
{
|
||||
'resource.id': 'resource-id-1',
|
||||
'resource.name': 'resource-test-1',
|
||||
'cloud.region': 'us-test-1',
|
||||
vulnerabilities_count: 4,
|
||||
severity_map: {
|
||||
critical: 1,
|
||||
high: 1,
|
||||
medium: 1,
|
||||
low: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
'resource.id': 'resource-id-2',
|
||||
'resource.name': 'resource-test-2',
|
||||
'cloud.region': 'us-test-1',
|
||||
vulnerabilities_count: 4,
|
||||
severity_map: {
|
||||
critical: 1,
|
||||
high: 1,
|
||||
medium: 1,
|
||||
low: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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 { css } from '@emotion/css';
|
||||
import {
|
||||
EuiColorPaletteDisplay,
|
||||
EuiToolTip,
|
||||
useEuiTheme,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { ColorStop } from '@elastic/eui/src/components/color_picker/color_stops';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { getSeverityStatusColor } from '../../../common/utils/get_vulnerability_colors';
|
||||
import { VulnSeverity } from '../../../../common/types';
|
||||
import { SeverityStatusBadge } from '../../../components/vulnerability_badges';
|
||||
|
||||
interface Props {
|
||||
total: number;
|
||||
severityMap: SeverityMap;
|
||||
}
|
||||
|
||||
interface SeverityMap {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
interface SeverityMapTooltip {
|
||||
severity: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const formatPercentage = (percentage: number) => {
|
||||
if (percentage === 0) {
|
||||
return '0%';
|
||||
}
|
||||
|
||||
if (percentage === 100) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
return `${percentage.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
export const SeverityMap = ({ severityMap, total }: Props) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const severityMapPallet: ColorStop[] = [];
|
||||
const severityMapTooltip: SeverityMapTooltip[] = [];
|
||||
|
||||
if (total > 0) {
|
||||
// Setting a minimum stop value of 8% the palette bar to avoid the color
|
||||
// palette being too small to be visible
|
||||
const minStop = Math.max(0.08 * total, 1);
|
||||
|
||||
const severityLevels: Array<keyof SeverityMap> = ['low', 'medium', 'high', 'critical'];
|
||||
|
||||
let currentStop = 0;
|
||||
|
||||
severityLevels.forEach((severity) => {
|
||||
if (severityMap[severity] > 0) {
|
||||
currentStop += Math.max(severityMap[severity], minStop);
|
||||
severityMapPallet.push({
|
||||
stop: currentStop,
|
||||
color: getSeverityStatusColor(severity.toUpperCase() as VulnSeverity),
|
||||
});
|
||||
}
|
||||
severityMapTooltip.push({
|
||||
severity,
|
||||
count: severityMap[severity],
|
||||
percentage: (severityMap[severity] / total) * 100,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiToolTip
|
||||
className={css`
|
||||
width: 256px;
|
||||
`}
|
||||
anchorClassName={css`
|
||||
height: ${euiTheme.size.xl};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
position="left"
|
||||
title={i18n.translate('xpack.csp.vulnerabilitiesByResource.severityMap.tooltipTitle', {
|
||||
defaultMessage: 'Severity map',
|
||||
})}
|
||||
content={<TooltipBody severityMapTooltip={severityMapTooltip} />}
|
||||
>
|
||||
<EuiColorPaletteDisplay
|
||||
type="fixed"
|
||||
palette={severityMapPallet}
|
||||
className={css`
|
||||
width: 100%;
|
||||
`}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
||||
|
||||
const TooltipBody = ({ severityMapTooltip }: { severityMapTooltip: SeverityMapTooltip[] }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{severityMapTooltip.map((severity) => (
|
||||
<EuiFlexGroup justifyContent="spaceBetween" key={severity.severity} alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<SeverityStatusBadge severity={severity.severity.toUpperCase() as VulnSeverity} />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">{severity.count}</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText
|
||||
textAlign="right"
|
||||
size="s"
|
||||
className={css`
|
||||
width: ${euiTheme.size.xxxl};
|
||||
color: ${euiTheme.colors.mediumShade};
|
||||
`}
|
||||
>
|
||||
{formatPercentage(severity.percentage)}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const VULNERABILITY_RESOURCE_COUNT = 'vulnerability_resource_count';
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 } from '@testing-library/react';
|
||||
import { VulnerabilitiesByResource } from './vulnerabilities_by_resource';
|
||||
import { TestProvider } from '../../../test/test_provider';
|
||||
import { useLatestVulnerabilitiesByResource } from '../hooks/use_latest_vulnerabilities_by_resource';
|
||||
import { VULNERABILITY_RESOURCE_COUNT } from './test_subjects';
|
||||
import { getVulnerabilitiesByResourceData } from './__mocks__/vulnerabilities_by_resource.mock';
|
||||
|
||||
jest.mock('../hooks/use_latest_vulnerabilities_by_resource', () => ({
|
||||
useLatestVulnerabilitiesByResource: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('VulnerabilitiesByResource', () => {
|
||||
const dataView: any = {};
|
||||
|
||||
const renderVulnerabilityByResource = () => {
|
||||
return render(
|
||||
<TestProvider>
|
||||
<VulnerabilitiesByResource dataView={dataView} />
|
||||
</TestProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders the loading state', () => {
|
||||
(useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
});
|
||||
renderVulnerabilityByResource();
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
it('renders the no data state', () => {
|
||||
(useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderVulnerabilityByResource();
|
||||
expect(screen.getByText(/no data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the empty state component', () => {
|
||||
(useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({
|
||||
data: { total: 0, total_vulnerabilities: 0, page: [] },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderVulnerabilityByResource();
|
||||
expect(screen.getByText(/no results/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Table', () => {
|
||||
(useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({
|
||||
data: getVulnerabilitiesByResourceData(),
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderVulnerabilityByResource();
|
||||
expect(screen.getByText(/2 resources/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/8 vulnerabilities/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/resource-id-1/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/resource-id-2/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/resource-test-1/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/us-test-1/i)).toHaveLength(2);
|
||||
expect(screen.getAllByTestId(VULNERABILITY_RESOURCE_COUNT)).toHaveLength(2);
|
||||
expect(screen.getAllByTestId(VULNERABILITY_RESOURCE_COUNT)[0]).toHaveTextContent('4');
|
||||
expect(screen.getAllByTestId(VULNERABILITY_RESOURCE_COUNT)[1]).toHaveTextContent('4');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,345 @@
|
|||
/*
|
||||
* 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,
|
||||
EuiButtonEmpty,
|
||||
EuiDataGrid,
|
||||
EuiDataGridCellValueElementProps,
|
||||
EuiDataGridColumnCellAction,
|
||||
EuiFlexItem,
|
||||
EuiProgress,
|
||||
EuiSpacer,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import React, { useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants';
|
||||
import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table';
|
||||
import { ErrorCallout } from '../../configurations/layout/error_callout';
|
||||
import { FindingsSearchBar } from '../../configurations/layout/findings_search_bar';
|
||||
import { useLimitProperties } from '../../../common/utils/get_limit_properties';
|
||||
import { LimitedResultsBar } from '../../configurations/layout/findings_layout';
|
||||
import {
|
||||
getVulnerabilitiesByResourceColumnsGrid,
|
||||
vulnerabilitiesByResourceColumns,
|
||||
} from './vulnerabilities_by_resource_table_columns';
|
||||
import {
|
||||
defaultLoadingRenderer,
|
||||
defaultNoDataRenderer,
|
||||
} from '../../../components/cloud_posture_page';
|
||||
import { getFilters } from '../utils/get_filters';
|
||||
import { FILTER_IN, FILTER_OUT, SEARCH_BAR_PLACEHOLDER, VULNERABILITIES } from '../translations';
|
||||
import { useStyles } from '../hooks/use_styles';
|
||||
import { FindingsGroupBySelector } from '../../configurations/layout/findings_group_by_selector';
|
||||
import { vulnerabilitiesPathnameHandler } from '../utils/vulnerabilities_pathname_handler';
|
||||
import { useLatestVulnerabilitiesByResource } from '../hooks/use_latest_vulnerabilities_by_resource';
|
||||
import { EmptyState } from '../../../components/empty_state';
|
||||
import { SeverityMap } from './severity_map';
|
||||
import { VULNERABILITY_RESOURCE_COUNT } from './test_subjects';
|
||||
|
||||
const getDefaultQuery = ({ query, filters }: any): any => ({
|
||||
query,
|
||||
filters,
|
||||
sort: [{ id: vulnerabilitiesByResourceColumns.vulnerabilities_count, direction: 'desc' }],
|
||||
pageIndex: 0,
|
||||
});
|
||||
|
||||
export const VulnerabilitiesByResource = ({ dataView }: { dataView: DataView }) => {
|
||||
const {
|
||||
pageIndex,
|
||||
query,
|
||||
sort,
|
||||
queryError,
|
||||
pageSize,
|
||||
onChangeItemsPerPage,
|
||||
onChangePage,
|
||||
onSort,
|
||||
urlQuery,
|
||||
setUrlQuery,
|
||||
onResetFilters,
|
||||
} = useCloudPostureTable({
|
||||
dataView,
|
||||
defaultQuery: getDefaultQuery,
|
||||
paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY,
|
||||
});
|
||||
|
||||
const styles = useStyles();
|
||||
const { data, isLoading, isFetching } = useLatestVulnerabilitiesByResource({
|
||||
query,
|
||||
sortOrder: sort[0]?.direction,
|
||||
enabled: !queryError,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({
|
||||
total: data?.total,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const getColumnIdValue = (rowIndex: number, columnId: string) => {
|
||||
const vulnerabilityRow = data?.page[rowIndex];
|
||||
if (!vulnerabilityRow) return null;
|
||||
|
||||
if (columnId === vulnerabilitiesByResourceColumns.resource_id) {
|
||||
return vulnerabilityRow['resource.id'];
|
||||
}
|
||||
if (columnId === vulnerabilitiesByResourceColumns.resource_name) {
|
||||
return vulnerabilityRow['resource.name'];
|
||||
}
|
||||
if (columnId === vulnerabilitiesByResourceColumns.region) {
|
||||
return vulnerabilityRow['cloud.region'];
|
||||
}
|
||||
};
|
||||
|
||||
const cellActions: EuiDataGridColumnCellAction[] = [
|
||||
({ Component, rowIndex, columnId }) => {
|
||||
const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex;
|
||||
|
||||
const value = getColumnIdValue(rowIndexFromPage, columnId);
|
||||
|
||||
if (!value) return null;
|
||||
return (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={i18n.translate(
|
||||
'xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButtonTooltip',
|
||||
{
|
||||
defaultMessage: 'Add {columnId} filter',
|
||||
values: { columnId },
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Component
|
||||
iconType="plusInCircle"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButton',
|
||||
{
|
||||
defaultMessage: 'Add {columnId} negated filter',
|
||||
values: { columnId },
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
setUrlQuery({
|
||||
pageIndex: 0,
|
||||
filters: getFilters({
|
||||
filters: urlQuery.filters,
|
||||
dataView,
|
||||
field: columnId,
|
||||
value,
|
||||
negate: false,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{FILTER_IN}
|
||||
</Component>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
({ Component, rowIndex, columnId }) => {
|
||||
const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex;
|
||||
|
||||
const value = getColumnIdValue(rowIndexFromPage, columnId);
|
||||
|
||||
if (!value) return null;
|
||||
return (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={i18n.translate(
|
||||
'xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegatedFilterButtonTooltip',
|
||||
{
|
||||
defaultMessage: 'Add {columnId} negated filter',
|
||||
values: { columnId },
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Component
|
||||
iconType="minusInCircle"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegateFilterButton',
|
||||
{
|
||||
defaultMessage: 'Add {columnId} negated filter',
|
||||
values: { columnId },
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
setUrlQuery({
|
||||
pageIndex: 0,
|
||||
filters: getFilters({
|
||||
filters: urlQuery.filters,
|
||||
dataView,
|
||||
field: columnId,
|
||||
value,
|
||||
negate: true,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{FILTER_OUT}
|
||||
</Component>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
return getVulnerabilitiesByResourceColumnsGrid(cellActions);
|
||||
}, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]);
|
||||
|
||||
const renderCellValue = useMemo(() => {
|
||||
const Cell: React.FC<EuiDataGridCellValueElementProps> = ({
|
||||
columnId,
|
||||
rowIndex,
|
||||
}): React.ReactElement | null => {
|
||||
const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex;
|
||||
|
||||
const resourceVulnerabilityRow = data?.page[rowIndexFromPage];
|
||||
|
||||
if (isFetching) return null;
|
||||
if (!resourceVulnerabilityRow?.['resource.id']) return null;
|
||||
|
||||
if (columnId === vulnerabilitiesByResourceColumns.resource_id) {
|
||||
return <>{resourceVulnerabilityRow['resource.id']}</>;
|
||||
}
|
||||
if (columnId === vulnerabilitiesByResourceColumns.resource_name) {
|
||||
return <>{resourceVulnerabilityRow['resource.name']}</>;
|
||||
}
|
||||
if (columnId === vulnerabilitiesByResourceColumns.region) {
|
||||
return <>{resourceVulnerabilityRow['cloud.region']}</>;
|
||||
}
|
||||
if (columnId === vulnerabilitiesByResourceColumns.vulnerabilities_count) {
|
||||
return (
|
||||
<EuiBadge color="hollow" data-test-subj={VULNERABILITY_RESOURCE_COUNT}>
|
||||
{resourceVulnerabilityRow.vulnerabilities_count}
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
||||
|
||||
if (columnId === vulnerabilitiesByResourceColumns.severity_map) {
|
||||
return (
|
||||
<SeverityMap
|
||||
total={resourceVulnerabilityRow.vulnerabilities_count}
|
||||
severityMap={resourceVulnerabilityRow.severity_map}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return Cell;
|
||||
}, [data?.page, pageSize, isFetching]);
|
||||
|
||||
const error = queryError || null;
|
||||
|
||||
if (error) {
|
||||
return <ErrorCallout error={error as Error} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return defaultLoadingRenderer();
|
||||
}
|
||||
|
||||
if (!data?.page) {
|
||||
return defaultNoDataRenderer();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FindingsSearchBar
|
||||
dataView={dataView}
|
||||
setQuery={(newQuery) => {
|
||||
setUrlQuery({ ...newQuery, pageIndex: 0 });
|
||||
}}
|
||||
loading={isLoading}
|
||||
placeholder={SEARCH_BAR_PLACEHOLDER}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
{!isLoading && data.page.length === 0 ? (
|
||||
<EmptyState onResetFilters={onResetFilters} />
|
||||
) : (
|
||||
<>
|
||||
<EuiProgress
|
||||
size="xs"
|
||||
color="accent"
|
||||
style={{
|
||||
opacity: isFetching ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
<EuiDataGrid
|
||||
className={styles.gridStyle}
|
||||
aria-label={VULNERABILITIES}
|
||||
columns={columns}
|
||||
columnVisibility={{
|
||||
visibleColumns: columns.map(({ id }) => id),
|
||||
setVisibleColumns: () => {},
|
||||
}}
|
||||
rowCount={limitedTotalItemCount}
|
||||
toolbarVisibility={{
|
||||
showColumnSelector: false,
|
||||
showDisplaySelector: false,
|
||||
showKeyboardShortcuts: false,
|
||||
showSortSelector: false,
|
||||
showFullScreenSelector: false,
|
||||
additionalControls: {
|
||||
left: {
|
||||
prepend: (
|
||||
<>
|
||||
<EuiButtonEmpty size="xs" color="text">
|
||||
{i18n.translate('xpack.csp.vulnerabilitiesByResource.totalResources', {
|
||||
defaultMessage: '{total, plural, one {# Resource} other {# Resources}}',
|
||||
values: { total: data?.total },
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
<EuiButtonEmpty size="xs" color="text">
|
||||
{i18n.translate(
|
||||
'xpack.csp.vulnerabilitiesByResource.totalVulnerabilities',
|
||||
{
|
||||
defaultMessage:
|
||||
'{total, plural, one {# Vulnerability} other {# Vulnerabilities}}',
|
||||
values: { total: data?.total_vulnerabilities },
|
||||
}
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
</>
|
||||
),
|
||||
},
|
||||
right: (
|
||||
<EuiFlexItem grow={false} className={styles.groupBySelector}>
|
||||
<FindingsGroupBySelector
|
||||
type="resource"
|
||||
pathnameHandler={vulnerabilitiesPathnameHandler}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
),
|
||||
},
|
||||
}}
|
||||
gridStyle={{
|
||||
border: 'horizontal',
|
||||
cellPadding: 'l',
|
||||
stripes: false,
|
||||
rowHover: 'none',
|
||||
header: 'underline',
|
||||
}}
|
||||
renderCellValue={renderCellValue}
|
||||
inMemory={{ level: 'enhancements' }}
|
||||
sorting={{ columns: sort, onSort }}
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageSizeOptions: [10, 25, 100],
|
||||
onChangeItemsPerPage,
|
||||
onChangePage,
|
||||
}}
|
||||
/>
|
||||
{isLastLimitedPage && <LimitedResultsBar />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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 { EuiDataGridColumn, EuiDataGridColumnCellAction } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const vulnerabilitiesByResourceColumns = {
|
||||
resource_id: 'resource.id',
|
||||
resource_name: 'resource.name',
|
||||
region: 'cloud.region',
|
||||
vulnerabilities_count: 'vulnerabilities_count',
|
||||
severity_map: 'severity_map',
|
||||
};
|
||||
|
||||
const defaultColumnProps = (): Partial<EuiDataGridColumn> => ({
|
||||
isExpandable: false,
|
||||
actions: {
|
||||
showHide: false,
|
||||
showMoveLeft: false,
|
||||
showMoveRight: false,
|
||||
showSortAsc: false,
|
||||
showSortDesc: false,
|
||||
},
|
||||
isSortable: false,
|
||||
});
|
||||
|
||||
export const getVulnerabilitiesByResourceColumnsGrid = (
|
||||
cellActions: EuiDataGridColumnCellAction[]
|
||||
): EuiDataGridColumn[] => [
|
||||
{
|
||||
...defaultColumnProps(),
|
||||
id: vulnerabilitiesByResourceColumns.resource_id,
|
||||
displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.resourceId', {
|
||||
defaultMessage: 'Resource ID',
|
||||
}),
|
||||
cellActions,
|
||||
},
|
||||
{
|
||||
...defaultColumnProps(),
|
||||
id: vulnerabilitiesByResourceColumns.resource_name,
|
||||
displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.resourceName', {
|
||||
defaultMessage: 'Resource Name',
|
||||
}),
|
||||
cellActions,
|
||||
},
|
||||
{
|
||||
...defaultColumnProps(),
|
||||
id: vulnerabilitiesByResourceColumns.region,
|
||||
displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.region', {
|
||||
defaultMessage: 'Region',
|
||||
}),
|
||||
cellActions,
|
||||
initialWidth: 150,
|
||||
},
|
||||
{
|
||||
...defaultColumnProps(),
|
||||
actions: {
|
||||
showHide: false,
|
||||
showMoveLeft: false,
|
||||
showMoveRight: false,
|
||||
showSortAsc: true,
|
||||
showSortDesc: true,
|
||||
},
|
||||
id: vulnerabilitiesByResourceColumns.vulnerabilities_count,
|
||||
displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.vulnerabilities', {
|
||||
defaultMessage: 'Vulnerabilities',
|
||||
}),
|
||||
initialWidth: 140,
|
||||
isResizable: false,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
...defaultColumnProps(),
|
||||
id: vulnerabilitiesByResourceColumns.severity_map,
|
||||
displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.severityMap', {
|
||||
defaultMessage: 'Severity Map',
|
||||
}),
|
||||
cellActions,
|
||||
initialWidth: 110,
|
||||
isResizable: false,
|
||||
},
|
||||
];
|
|
@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { NvdLogo } from '../../../assets/icons/nvd_logo_svg';
|
||||
import { CVSScoreBadge } from '../../../components/vulnerability_badges';
|
||||
import { CVSScoreProps, VectorScoreBase, Vendor, Vulnerability } from '../types';
|
||||
import { getVectorScoreList } from '../utils';
|
||||
import { getVectorScoreList } from '../utils/get_vector_score_list';
|
||||
import { OVERVIEW_TAB_VULNERABILITY_FLYOUT } from '../test_subjects';
|
||||
import redhatLogo from '../../../assets/icons/redhat_logo.svg';
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { SEVERITY } from '../../../common/constants';
|
||||
import { VULNERABILITIES_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';
|
||||
|
@ -40,42 +40,42 @@ export const VulnerabilityStatistics = () => {
|
|||
id: 'critical-count-stat',
|
||||
title: <CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.criticalCount} />,
|
||||
description: (
|
||||
<EuiHealth color={getSeverityStatusColor(SEVERITY.CRITICAL)}>
|
||||
<EuiHealth color={getSeverityStatusColor(VULNERABILITIES_SEVERITY.CRITICAL)}>
|
||||
{i18n.translate('xpack.csp.cnvmDashboard.statistics.criticalTitle', {
|
||||
defaultMessage: 'Critical',
|
||||
})}
|
||||
</EuiHealth>
|
||||
),
|
||||
onClick: () => {
|
||||
navToVulnerabilities({ 'vulnerability.severity': SEVERITY.CRITICAL });
|
||||
navToVulnerabilities({ 'vulnerability.severity': VULNERABILITIES_SEVERITY.CRITICAL });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'high-count-stat',
|
||||
title: <CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.highCount} />,
|
||||
description: (
|
||||
<EuiHealth color={getSeverityStatusColor(SEVERITY.HIGH)}>
|
||||
<EuiHealth color={getSeverityStatusColor(VULNERABILITIES_SEVERITY.HIGH)}>
|
||||
{i18n.translate('xpack.csp.cnvmDashboard.statistics.highTitle', {
|
||||
defaultMessage: 'High',
|
||||
})}
|
||||
</EuiHealth>
|
||||
),
|
||||
onClick: () => {
|
||||
navToVulnerabilities({ 'vulnerability.severity': SEVERITY.HIGH });
|
||||
navToVulnerabilities({ 'vulnerability.severity': VULNERABILITIES_SEVERITY.HIGH });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'medium-count-stat',
|
||||
title: <CompactFormattedNumber number={getCnvmStats.data?.cnvmStatistics.mediumCount} />,
|
||||
description: (
|
||||
<EuiHealth color={getSeverityStatusColor(SEVERITY.MEDIUM)}>
|
||||
<EuiHealth color={getSeverityStatusColor(VULNERABILITIES_SEVERITY.MEDIUM)}>
|
||||
{i18n.translate('xpack.csp.cnvmDashboard.statistics.mediumTitle', {
|
||||
defaultMessage: 'Medium',
|
||||
})}
|
||||
</EuiHealth>
|
||||
),
|
||||
onClick: () => {
|
||||
navToVulnerabilities({ 'vulnerability.severity': SEVERITY.MEDIUM });
|
||||
navToVulnerabilities({ 'vulnerability.severity': VULNERABILITIES_SEVERITY.MEDIUM });
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -85,7 +85,7 @@ export const VulnerabilityStatistics = () => {
|
|||
return (
|
||||
<EuiFlexGroup>
|
||||
{stats.map((stat) => (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexItem key={stat.id}>
|
||||
<CspCounterCard {...stat} />
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
|
|
|
@ -7,7 +7,10 @@
|
|||
|
||||
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';
|
||||
import {
|
||||
LATEST_VULNERABILITIES_INDEX_DEFAULT_NS,
|
||||
VULNERABILITIES_SEVERITY,
|
||||
} from '../../../common/constants';
|
||||
|
||||
export interface VulnerabilitiesStatisticsQueryResult {
|
||||
critical: {
|
||||
|
@ -35,13 +38,13 @@ export const getVulnerabilitiesStatisticsQuery = (
|
|||
index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS,
|
||||
aggs: {
|
||||
critical: {
|
||||
filter: { term: { 'vulnerability.severity': 'CRITICAL' } },
|
||||
filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.CRITICAL } },
|
||||
},
|
||||
high: {
|
||||
filter: { term: { 'vulnerability.severity': 'HIGH' } },
|
||||
filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.HIGH } },
|
||||
},
|
||||
medium: {
|
||||
filter: { term: { 'vulnerability.severity': 'MEDIUM' } },
|
||||
filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.MEDIUM } },
|
||||
},
|
||||
resources_scanned: {
|
||||
cardinality: {
|
||||
|
|
|
@ -7,10 +7,8 @@
|
|||
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import type { CnvmDashboardData } from '../../../common/types';
|
||||
import {
|
||||
VULNERABILITIES_DASHBOARD_ROUTE_PATH,
|
||||
getSafeVulnerabilitiesQueryFilter,
|
||||
} from '../../../common/constants';
|
||||
import { VULNERABILITIES_DASHBOARD_ROUTE_PATH } from '../../../common/constants';
|
||||
import { getSafeVulnerabilitiesQueryFilter } from '../../../common/utils/get_safe_vulnerabilities_query_filter';
|
||||
import { CspRouter } from '../../types';
|
||||
import { getVulnerabilitiesStatistics } from './get_vulnerabilities_statistics';
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue