diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts index c2104478edb87..cabc449b1e3bd 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts @@ -34,10 +34,18 @@ export const useFilteredDataView = (indexPattern: string) => { throw new Error('Error fetching fields for the index pattern'); } + // Filter out fields that are not present in the index pattern passed as a parameter dataView.fields = dataView.fields.filter((field) => indexPatternFields.some((indexPatternField) => indexPatternField.name === field.name) ) as DataView['fields']; + // Insert fields that are present in the index pattern but not in the data view + indexPatternFields.forEach((indexPatternField) => { + if (!dataView.fields.some((field) => field.name === indexPatternField.name)) { + dataView.fields.push(indexPatternField as DataView['fields'][0]); + } + }); + return dataView; }; 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 38985d1d99222..638076818d3f0 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 @@ -31,3 +31,5 @@ export const NO_VULNERABILITIES_STATUS_TEST_SUBJ = { }; export const VULNERABILITIES_CONTAINER_TEST_SUBJ = 'vulnerabilities_container'; + +export const VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ = 'vuknerabilities_cvss_score_badge'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx b/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx index 3f2e729446b12..b94efbd2de1a0 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx @@ -11,6 +11,7 @@ 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'; +import { VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ } from './test_subjects'; interface CVSScoreBadgeProps { score: float; @@ -32,9 +33,9 @@ export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => { .euiBadge__text { display: flex; } - display: flex; width: 62px; `} + data-test-subj={VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ} > {versionDisplay && ( <> 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 978a4ed2d9710..1ea6a460a41fc 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 @@ -5,17 +5,12 @@ * 2.0. */ import React, { useCallback } from 'react'; -import { - EuiSpacer, - EuiButtonEmpty, - type EuiDescriptionListProps, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiSpacer, EuiButtonEmpty, type EuiDescriptionListProps } from '@elastic/eui'; import { Link, useParams } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; import { generatePath } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; import { CspInlineDescriptionList } from '../../../../components/csp_inline_description_list'; import type { Evaluation } from '../../../../../common/types'; import { CspFinding } from '../../../../../common/schemas/csp_finding'; @@ -50,7 +45,14 @@ const getDefaultQuery = ({ const BackToResourcesButton = () => ( - + { }} loading={resourceFindings.isFetching} /> - - - - - - } + + + + - - - - - - + } + /> + {resourceFindings.data && ( ( ); -export const PageTitleText = ({ title }: { title: React.ReactNode }) =>

{title}

; +export const PageTitleText = ({ title }: { title: React.ReactNode }) => ( + +

{title}

+
+); export const getExpandColumn = ({ onClick, 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 b268914134d67..4d9283da03d74 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 @@ -16,7 +16,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; -import { Redirect, Switch, useHistory, useLocation } from 'react-router-dom'; +import { Redirect, Switch, useHistory, useLocation, matchPath } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { Configurations } from '../configurations'; import { cloudPosturePages, findingsNavigation } from '../../common/navigation/constants'; @@ -33,63 +33,76 @@ export const Findings = () => { history.push({ pathname: findingsNavigation.findings_default.path }); }; + const isResourcesVulnerabilitiesPage = matchPath(location.pathname, { + path: findingsNavigation.resource_vulnerabilities.path, + })?.isExact; + + const isResourcesFindingsPage = matchPath(location.pathname, { + path: findingsNavigation.resource_findings.path, + })?.isExact; + + const showHeader = !isResourcesVulnerabilitiesPage && !isResourcesFindingsPage; + const isVulnerabilitiesTabSelected = (pathname: string) => { return ( pathname === findingsNavigation.vulnerabilities.path || - pathname === findingsNavigation.vulnerabilities_by_resource.path || - pathname === findingsNavigation.resource_vulnerabilities.path + pathname === findingsNavigation.vulnerabilities_by_resource.path ); }; return ( <> - -

- -

-
- - - - - - - - - + +

+ +

+
+ + + + + + + + + } + tooltipPosition="bottom" /> - } - tooltipPosition="bottom" + + + + + -
-
-
- - - -
+ + + + )} ; type LatestFindingsResponse = IKibanaSearchResponse>; @@ -59,7 +60,7 @@ export const useLatestVulnerabilities = (options: VulnerabilitiesQuery) => { ); return { - page: hits.hits.map((hit) => hit._source!), + page: hits.hits.map((hit) => hit._source!) as VulnerabilityRecord[], total: number.is(hits.total) ? hits.total : 0, }; }, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_by_resource.ts index 42b7cc76c55a9..706f980408d18 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_by_resource.ts @@ -118,13 +118,17 @@ export const getQuery = ({ }); const getFirstKey = ( buckets: AggregationsMultiBucketAggregateBase['buckets'] -): undefined | string => { - if (!!Array.isArray(buckets) && !!buckets.length) return buckets[0].key; +) => { + return !!Array.isArray(buckets) && !!buckets.length ? (buckets[0].key as string) : ''; }; const createVulnerabilitiesByResource = (resource: FindingsAggBucket) => ({ - 'resource.id': resource.key, - 'resource.name': getFirstKey(resource.name.buckets), - 'cloud.region': getFirstKey(resource.region.buckets), + resource: { + id: resource.key, + name: getFirstKey(resource.name.buckets), + }, + cloud: { + region: getFirstKey(resource.region.buckets), + }, vulnerabilities_count: resource.doc_count, severity_map: { critical: resource.critical.doc_count, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts index ff0bfce6c8cf3..fea25c64d0978 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts @@ -39,6 +39,10 @@ export const useStyles = () => { & .euiDataGridRowCell__expandActions > [data-test-subj='euiDataGridCellExpandButton'] { display: none; } + & .euiDataGridRowCell__contentByHeight + .euiDataGridRowCell__expandActions { + padding: 0; + } + & .euiDataGridRowCell__expandFlex { align-items: center; } diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts index def4c543b5fcd..7ff7861954a61 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts @@ -52,22 +52,22 @@ export interface VulnerabilityRecord { version: string; }; cloud: { - image: { + image?: { id: string; }; - provider: string; - instance: { + provider?: string; + instance?: { id: string; }; - machine: { + machine?: { type: string; }; region: string; - availability_zone: string; - service: { + availability_zone?: string; + service?: { name: string; }; - account: { + account?: { id: string; }; }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.tsx new file mode 100644 index 0000000000000..edafbd1f763f6 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.tsx @@ -0,0 +1,159 @@ +/* + * 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 { EuiDataGridColumn, EuiDataGridColumnCellAction, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { VulnerabilityRecord } from '../types'; +import { getFilters } from './get_filters'; +import { FILTER_IN, FILTER_OUT } from '../translations'; + +export const getVulnerabilitiesGridCellActions = >>({ + data, + columns, + columnGridFn, + pageSize, + setUrlQuery, + filters, + dataView, +}: { + data: T; + columns: Record; + columnGridFn: (cellActions: EuiDataGridColumnCellAction[]) => EuiDataGridColumn[]; + pageSize: number; + setUrlQuery: (query: any) => void; + filters: any; + dataView: any; +}) => { + const getColumnIdValue = (rowIndex: number, columnId: string) => { + const vulnerabilityRow = data[rowIndex]; + if (!vulnerabilityRow) return null; + + if (columnId === columns.vulnerability) { + return vulnerabilityRow.vulnerability?.id; + } + if (columnId === columns.cvss) { + return vulnerabilityRow.vulnerability?.score.base; + } + if (columnId === columns.resource) { + return vulnerabilityRow.resource?.name; + } + if (columnId === columns.severity) { + return vulnerabilityRow.vulnerability?.severity; + } + if (columnId === columns.package) { + return vulnerabilityRow.vulnerability?.package?.name; + } + if (columnId === columns.version) { + return vulnerabilityRow.vulnerability?.package?.version; + } + if (columnId === columns.fix_version) { + return vulnerabilityRow.vulnerability?.package?.fixed_version; + } + if (columnId === columns.resource_id) { + return vulnerabilityRow.resource?.id; + } + if (columnId === columns.resource_name) { + return vulnerabilityRow.resource?.name; + } + if (columnId === columns.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 ( + + { + setUrlQuery({ + pageIndex: 0, + filters: getFilters({ + filters, + dataView, + field: columnId, + value, + negate: false, + }), + }); + }} + > + {FILTER_IN} + + + ); + }, + ({ Component, rowIndex, columnId }) => { + const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; + + const value = getColumnIdValue(rowIndexFromPage, columnId); + + if (!value) return null; + return ( + + { + setUrlQuery({ + pageIndex: 0, + filters: getFilters({ + filters, + dataView, + field: columnId, + value, + negate: true, + }), + }); + }} + > + {FILTER_OUT} + + + ); + }, + ]; + + return columnGridFn(cellActions); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx index 4d688c1d02cf3..7733d3010e281 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx @@ -9,11 +9,9 @@ import { EuiButtonIcon, EuiDataGrid, EuiDataGridCellValueElementProps, - EuiDataGridColumnCellAction, EuiFlexItem, EuiProgress, EuiSpacer, - EuiToolTip, useEuiTheme, } from '@elastic/eui'; import { cx } from '@emotion/css'; @@ -42,8 +40,7 @@ import { vulnerabilitiesColumns, } from './vulnerabilities_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 { SEARCH_BAR_PLACEHOLDER, VULNERABILITIES } from './translations'; import { severitySchemaConfig, severitySortScript, @@ -54,6 +51,8 @@ import { FindingsGroupBySelector } from '../configurations/layout/findings_group import { vulnerabilitiesPathnameHandler } from './utils/vulnerabilities_pathname_handler'; import { findingsNavigation } from '../../common/navigation/constants'; import { VulnerabilitiesByResource } from './vulnerabilities_by_resource/vulnerabilities_by_resource'; +import { ResourceVulnerabilities } from './vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities'; +import { getVulnerabilitiesGridCellActions } from './utils/get_vulnerabilities_grid_cell_actions'; const getDefaultQuery = ({ query, filters }: any): any => ({ query, @@ -84,6 +83,11 @@ export const Vulnerabilities = () => { return ( + } + /> { }); const columns = useMemo(() => { - const getColumnIdValue = (rowIndex: number, columnId: string) => { - const vulnerabilityRow = data?.page[rowIndex] as VulnerabilityRecord; - if (!vulnerabilityRow) return null; - - if (columnId === vulnerabilitiesColumns.vulnerability) { - return vulnerabilityRow.vulnerability.id; - } - if (columnId === vulnerabilitiesColumns.cvss) { - return vulnerabilityRow.vulnerability.score.base; - } - if (columnId === vulnerabilitiesColumns.resource) { - return vulnerabilityRow.resource?.name; - } - if (columnId === vulnerabilitiesColumns.severity) { - return vulnerabilityRow.vulnerability.severity; - } - if (columnId === vulnerabilitiesColumns.package) { - return vulnerabilityRow.vulnerability?.package?.name; - } - if (columnId === vulnerabilitiesColumns.version) { - return vulnerabilityRow.vulnerability?.package?.version; - } - if (columnId === vulnerabilitiesColumns.fix_version) { - return vulnerabilityRow.vulnerability.package?.fixed_version; - } - }; - - const cellActions: EuiDataGridColumnCellAction[] = [ - ({ Component, rowIndex, columnId }) => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const value = getColumnIdValue(rowIndexFromPage, columnId); - - if (!value) return null; - return ( - - { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field: columnId, - value, - negate: false, - }), - }); - }} - > - {FILTER_IN} - - - ); - }, - ({ Component, rowIndex, columnId }) => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const value = getColumnIdValue(rowIndexFromPage, columnId); - - if (!value) return null; - return ( - - { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field: columnId, - value, - negate: true, - }), - }); - }} - > - {FILTER_OUT} - - - ); - }, - ]; - - return getVulnerabilitiesColumnsGrid(cellActions); + if (!data?.page) { + return []; + } + return getVulnerabilitiesGridCellActions({ + columnGridFn: getVulnerabilitiesColumnsGrid, + columns: vulnerabilitiesColumns, + dataView, + pageSize, + data: data.page, + setUrlQuery, + filters: urlQuery.filters, + }); }, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]); const flyoutVulnerabilityIndex = urlQuery?.vulnerabilityIndex; @@ -520,7 +417,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { }} /> {isLastLimitedPage && } - {showVulnerabilityFlyout && ( + {showVulnerabilityFlyout && selectedVulnerability && ( ({ total_vulnerabilities: 8, page: [ { - 'resource.id': 'resource-id-1', - 'resource.name': 'resource-test-1', - 'cloud.region': 'us-test-1', + resource: { id: 'resource-id-1', name: 'resource-test-1' }, + cloud: { region: 'us-test-1' }, vulnerabilities_count: 4, severity_map: { critical: 1, @@ -22,9 +21,8 @@ export const getVulnerabilitiesByResourceData = () => ({ }, }, { - 'resource.id': 'resource-id-2', - 'resource.name': 'resource-test-2', - 'cloud.region': 'us-test-1', + resource: { id: 'resource-id-2', name: 'resource-test-2' }, + cloud: { region: 'us-test-1' }, vulnerabilities_count: 4, severity_map: { critical: 1, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/__mocks__/resource_vulnerabilities.mock.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/__mocks__/resource_vulnerabilities.mock.ts new file mode 100644 index 0000000000000..8328192062cc0 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/__mocks__/resource_vulnerabilities.mock.ts @@ -0,0 +1,122 @@ +/* + * 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 getResourceVulnerabilitiesMockData = () => ({ + page: [ + { + agent: { + name: 'ip-172-31-15-210', + id: '2d262db5-b637-4e46-a2a0-db409825ff46', + ephemeral_id: '2af1be77-0bdf-4313-b375-592848fe60d7', + type: 'cloudbeat', + version: '8.8.0', + }, + package: { + path: 'usr/lib/snapd/snapd', + fixed_version: '3.0.0-20220521103104-8f96da9f5d5e', + name: 'gopkg.in/yaml.v3', + type: 'gobinary', + version: 'v3.0.0-20210107192922-496545a6307b', + }, + resource: { + name: 'elastic-agent-instance-a6c683d0-0977-11ee-bb0b-0af2059ffbbf', + id: '0d103e99f17f355ba', + }, + elastic_agent: { + id: '2d262db5-b637-4e46-a2a0-db409825ff46', + version: '8.8.0', + snapshot: false, + }, + vulnerability: { + severity: 'HIGH', + package: { + fixed_version: '3.0.0-20220521103104-8f96da9f5d5e', + name: 'gopkg.in/yaml.v3', + version: 'v3.0.0-20210107192922-496545a6307b', + }, + description: + 'An issue in the Unmarshal function in Go-Yaml v3 causes the program to crash when attempting to deserialize invalid input.', + title: 'crash when attempting to deserialize invalid input', + classification: 'CVSS', + data_source: { + ID: 'go-vulndb', + URL: 'https://github.com/golang/vulndb', + Name: 'The Go Vulnerability Database', + }, + cwe: ['CWE-502'], + reference: 'https://avd.aquasec.com/nvd/cve-2022-28948', + score: { + version: '3.1', + base: 7.5, + }, + report_id: 1686633719, + scanner: { + vendor: 'Trivy', + version: 'v0.35.0', + }, + id: 'CVE-2022-28948', + enumeration: 'CVE', + published_date: '2022-05-19T20:15:00Z', + class: 'lang-pkgs', + cvss: { + redhat: { + V3Vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + V3Score: 7.5, + }, + nvd: { + V3Vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + V2Vector: 'AV:N/AC:L/Au:N/C:N/I:N/A:P', + V3Score: 7.5, + V2Score: 5, + }, + ghsa: { + V3Vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + V3Score: 7.5, + }, + }, + }, + cloud: { + provider: 'aws', + region: 'us-east-1', + account: { + name: 'elastic-security-cloud-security-dev', + id: '704479110758', + }, + }, + '@timestamp': '2023-06-13T06:15:16.182Z', + cloudbeat: { + commit_sha: '8497f3a4b4744c645233c5a13b45400367411c2f', + commit_time: '2023-05-09T16:07:58Z', + version: '8.8.0', + }, + ecs: { + version: '8.6.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'cloud_security_posture.vulnerabilities', + }, + host: { + name: 'ip-172-31-15-210', + }, + event: { + agent_id_status: 'auth_metadata_missing', + sequence: 1686633719, + ingested: '2023-06-15T18:37:56Z', + created: '2023-06-13T06:15:16.18250081Z', + kind: 'state', + id: '5cad2983-4a74-455d-ab39-6c584acd3994', + type: ['info'], + category: ['vulnerability'], + dataset: 'cloud_security_posture.vulnerabilities', + outcome: 'success', + }, + }, + ], + total: 1, +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.test.tsx new file mode 100644 index 0000000000000..9de1a61bebe38 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.test.tsx @@ -0,0 +1,123 @@ +/* + * 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 { useParams } from 'react-router-dom'; +import { ResourceVulnerabilities } from './resource_vulnerabilities'; +import { TestProvider } from '../../../../test/test_provider'; +import { useLatestVulnerabilities } from '../../hooks/use_latest_vulnerabilities'; +import { getResourceVulnerabilitiesMockData } from './__mocks__/resource_vulnerabilities.mock'; +import { VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ } from '../../../../components/test_subjects'; + +jest.mock('../../hooks/use_latest_vulnerabilities', () => ({ + useLatestVulnerabilities: jest.fn(), +})); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn().mockReturnValue({ + integration: undefined, + }), +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('ResourceVulnerabilities', () => { + const dataView: any = {}; + + const renderVulnerabilityByResource = () => { + return render( + + + + ); + }; + + it('renders the loading state', () => { + (useLatestVulnerabilities as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + }); + renderVulnerabilityByResource(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + it('renders the no data state', () => { + (useLatestVulnerabilities as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + }); + + renderVulnerabilityByResource(); + expect(screen.getByText(/no data/i)).toBeInTheDocument(); + }); + + it('applies the correct filter on fetch', () => { + const resourceId = 'test'; + (useParams as jest.Mock).mockReturnValue({ + resourceId, + }); + renderVulnerabilityByResource(); + expect(useLatestVulnerabilities).toHaveBeenCalledWith( + expect.objectContaining({ + query: { + bool: { + filter: [ + { + term: { + 'resource.id': resourceId, + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }, + }) + ); + }); + + it('renders the empty state component', () => { + (useLatestVulnerabilities 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', () => { + (useLatestVulnerabilities as jest.Mock).mockReturnValue({ + data: getResourceVulnerabilitiesMockData(), + isLoading: false, + isFetching: false, + }); + + renderVulnerabilityByResource(); + + // Header + expect(screen.getByText(/0d103e99f17f355ba/i)).toBeInTheDocument(); + expect(screen.getByText(/us-east-1/i)).toBeInTheDocument(); + expect( + screen.getByText(/elastic-agent-instance-a6c683d0-0977-11ee-bb0b-0af2059ffbbf/i) + ).toBeInTheDocument(); + + // Table + expect(screen.getByText(/CVE-2022-28948/i)).toBeInTheDocument(); + expect(screen.getByTestId(VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ)).toHaveTextContent(/7.5/i); + expect(screen.getByTestId(VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ)).toHaveTextContent(/v3/i); + expect(screen.getByText(/high/i)).toBeInTheDocument(); + expect(screen.getByText(/gopkg.in\/yaml.v3/i)).toBeInTheDocument(); + expect(screen.getByText(/v3.0.0-20210107192922-496545a6307b/i)).toBeInTheDocument(); + expect(screen.getByText(/3.0.0-20220521103104-8f96da9f5d5e/i)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.tsx new file mode 100644 index 0000000000000..f40196d4f4a14 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.tsx @@ -0,0 +1,443 @@ +/* + * 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 { + EuiButtonEmpty, + EuiButtonIcon, + EuiDataGrid, + EuiDataGridCellValueElementProps, + EuiProgress, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; +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 { FormattedMessage } from '@kbn/i18n-react'; +import { Link, useParams, generatePath } from 'react-router-dom'; +import { css } from '@emotion/react'; +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'; +import { VulnerabilityRecord } from '../../types'; +import { ErrorCallout } from '../../../configurations/layout/error_callout'; +import { FindingsSearchBar } from '../../../configurations/layout/findings_search_bar'; +import { CVSScoreBadge, SeverityStatusBadge } from '../../../../components/vulnerability_badges'; +import { EmptyState } from '../../../../components/empty_state'; +import { VulnerabilityFindingFlyout } from '../../vulnerabilities_finding_flyout/vulnerability_finding_flyout'; +import { useLimitProperties } from '../../../../common/utils/get_limit_properties'; +import { + LimitedResultsBar, + PageTitle, + PageTitleText, +} from '../../../configurations/layout/findings_layout'; +import { + getVulnerabilitiesColumnsGrid, + vulnerabilitiesColumns, +} from '../../vulnerabilities_table_columns'; +import { + defaultLoadingRenderer, + defaultNoDataRenderer, +} from '../../../../components/cloud_posture_page'; +import { SEARCH_BAR_PLACEHOLDER, VULNERABILITIES } from '../../translations'; +import { + severitySchemaConfig, + severitySortScript, + getCaseInsensitiveSortScript, +} from '../../utils/custom_sort_script'; +import { useStyles } from '../../hooks/use_styles'; +import { findingsNavigation } from '../../../../common/navigation/constants'; +import { CspInlineDescriptionList } from '../../../../components/csp_inline_description_list'; +import { getVulnerabilitiesGridCellActions } from '../../utils/get_vulnerabilities_grid_cell_actions'; + +const getDefaultQuery = ({ query, filters }: any) => ({ + query, + filters, + sort: [ + { id: vulnerabilitiesColumns.severity, direction: 'desc' }, + { id: vulnerabilitiesColumns.cvss, direction: 'desc' }, + ], + pageIndex: 0, +}); + +export const ResourceVulnerabilities = ({ dataView }: { dataView: DataView }) => { + const params = useParams<{ resourceId: string }>(); + const resourceId = decodeURIComponent(params.resourceId); + + const { + pageIndex, + query, + sort, + queryError, + pageSize, + onChangeItemsPerPage, + onChangePage, + onSort, + urlQuery, + setUrlQuery, + onResetFilters, + } = useCloudPostureTable({ + dataView, + defaultQuery: getDefaultQuery, + paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, + }); + const { euiTheme } = useEuiTheme(); + const styles = useStyles(); + + const [showHighlight, setHighlight] = useState(false); + + const onSortHandler = useCallback( + (newSort: any) => { + onSort(newSort); + if (newSort.length !== sort.length) { + setHighlight(true); + setTimeout(() => { + setHighlight(false); + }, 2000); + } + }, + [onSort, sort] + ); + + const multiFieldsSort = useMemo(() => { + return sort.map(({ id, direction }: { id: string; direction: string }) => { + if (id === vulnerabilitiesColumns.severity) { + return severitySortScript(direction); + } + if (id === vulnerabilitiesColumns.package) { + return getCaseInsensitiveSortScript(id, direction); + } + + return { + [id]: direction, + }; + }); + }, [sort]); + + const { data, isLoading, isFetching } = useLatestVulnerabilities({ + query: { + ...query, + bool: { + ...query!.bool, + filter: [...(query?.bool?.filter || []), { term: { 'resource.id': resourceId } }], + }, + }, + sort: multiFieldsSort, + enabled: !queryError, + pageIndex, + pageSize, + }); + + const invalidIndex = -1; + + const selectedVulnerability = useMemo(() => { + return data?.page[urlQuery.vulnerabilityIndex]; + }, [data?.page, urlQuery.vulnerabilityIndex]); + + const onCloseFlyout = () => { + setUrlQuery({ + vulnerabilityIndex: invalidIndex, + }); + }; + + const onOpenFlyout = useCallback( + (vulnerabilityRow: VulnerabilityRecord) => { + const vulnerabilityIndex = data?.page.findIndex( + (vulnerabilityRecord: VulnerabilityRecord) => + vulnerabilityRecord.vulnerability?.id === vulnerabilityRow.vulnerability?.id && + vulnerabilityRecord.resource?.id === vulnerabilityRow.resource?.id && + vulnerabilityRecord.vulnerability.package.name === + vulnerabilityRow.vulnerability.package.name && + vulnerabilityRecord.vulnerability.package.version === + vulnerabilityRow.vulnerability.package.version + ); + setUrlQuery({ + vulnerabilityIndex, + }); + }, + [setUrlQuery, data?.page] + ); + + const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ + total: data?.total, + pageIndex, + pageSize, + }); + + const columns = useMemo(() => { + if (!data?.page) { + return []; + } + return getVulnerabilitiesGridCellActions({ + columnGridFn: getVulnerabilitiesColumnsGrid, + columns: vulnerabilitiesColumns, + dataView, + pageSize, + data: data.page, + setUrlQuery, + filters: urlQuery.filters, + }).filter((column) => column.id !== vulnerabilitiesColumns.resource); + }, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]); + + const flyoutVulnerabilityIndex = urlQuery?.vulnerabilityIndex; + + const selectedVulnerabilityIndex = flyoutVulnerabilityIndex + pageIndex * pageSize; + + const renderCellValue = useMemo(() => { + const Cell: React.FC = ({ + columnId, + rowIndex, + setCellProps, + }): React.ReactElement | null => { + const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; + + const vulnerabilityRow = data?.page[rowIndexFromPage] as VulnerabilityRecord; + + useEffect(() => { + if (selectedVulnerabilityIndex === rowIndex) { + setCellProps({ + style: { + backgroundColor: euiTheme.colors.highlight, + }, + }); + } else { + setCellProps({ + style: { + backgroundColor: 'inherit', + }, + }); + } + }, [rowIndex, setCellProps]); + + if (isFetching) return null; + if (!vulnerabilityRow) return null; + if (!vulnerabilityRow.vulnerability?.id) return null; + + if (columnId === vulnerabilitiesColumns.actions) { + return ( + { + onOpenFlyout(vulnerabilityRow); + }} + /> + ); + } + if (columnId === vulnerabilitiesColumns.vulnerability) { + return <>{vulnerabilityRow.vulnerability?.id}; + } + if (columnId === vulnerabilitiesColumns.cvss) { + if ( + !vulnerabilityRow.vulnerability.score?.base || + !vulnerabilityRow.vulnerability.score?.version + ) { + return null; + } + return ( + + ); + } + if (columnId === vulnerabilitiesColumns.resource) { + return <>{vulnerabilityRow.resource?.name}; + } + if (columnId === vulnerabilitiesColumns.severity) { + if (!vulnerabilityRow.vulnerability.severity) { + return null; + } + return ; + } + + if (columnId === vulnerabilitiesColumns.package) { + return <>{vulnerabilityRow.vulnerability?.package?.name}; + } + if (columnId === vulnerabilitiesColumns.version) { + return <>{vulnerabilityRow.vulnerability?.package?.version}; + } + if (columnId === vulnerabilitiesColumns.fix_version) { + return <>{vulnerabilityRow.vulnerability?.package?.fixed_version}; + } + + return null; + }; + + return Cell; + }, [ + data?.page, + euiTheme.colors.highlight, + onOpenFlyout, + pageSize, + selectedVulnerabilityIndex, + isFetching, + ]); + + const onPaginateFlyout = useCallback( + (nextVulnerabilityIndex: number) => { + // the index of the vulnerability in the current page + const newVulnerabilityIndex = nextVulnerabilityIndex % pageSize; + + // if the vulnerability is not in the current page, we need to change the page + const flyoutPageIndex = Math.floor(nextVulnerabilityIndex / pageSize); + + setUrlQuery({ + pageIndex: flyoutPageIndex, + vulnerabilityIndex: newVulnerabilityIndex, + }); + }, + [pageSize, setUrlQuery] + ); + + const error = queryError || null; + + if (error) { + return ; + } + if (isLoading) { + return defaultLoadingRenderer(); + } + + if (!data?.page) { + return defaultNoDataRenderer(); + } + + const showVulnerabilityFlyout = flyoutVulnerabilityIndex > invalidIndex; + + return ( + <> + { + setUrlQuery({ ...newQuery, pageIndex: 0 }); + }} + loading={isLoading} + placeholder={SEARCH_BAR_PLACEHOLDER} + /> + + + + + + + + + + + + + + {!isLoading && data?.page.length === 0 ? ( + + ) : ( + <> + + id), + setVisibleColumns: () => {}, + }} + height={undefined} + width={undefined} + schemaDetectors={[severitySchemaConfig]} + rowCount={limitedTotalItemCount} + rowHeightsOptions={{ + defaultHeight: 40, + }} + toolbarVisibility={{ + showColumnSelector: false, + showDisplaySelector: false, + showKeyboardShortcuts: false, + showFullScreenSelector: false, + additionalControls: { + left: { + prepend: ( + <> + + {i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', { + defaultMessage: + '{total, plural, one {# Vulnerability} other {# Vulnerabilities}}', + values: { total: data?.total }, + })} + + + ), + }, + }, + }} + gridStyle={{ + border: 'horizontal', + cellPadding: 'l', + stripes: false, + rowHover: 'none', + header: 'underline', + }} + renderCellValue={renderCellValue} + inMemory={{ level: 'enhancements' }} + sorting={{ columns: sort, onSort: onSortHandler }} + pagination={{ + pageIndex, + pageSize, + pageSizeOptions: [10, 25, 100], + onChangeItemsPerPage, + onChangePage, + }} + /> + {isLastLimitedPage && } + {showVulnerabilityFlyout && selectedVulnerability && ( + + )} + + )} + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.tsx index 5e2ac7e199e5a..9e4e47707e142 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.tsx @@ -9,16 +9,16 @@ import { 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 { Link, generatePath } from 'react-router-dom'; import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants'; +import { findingsNavigation } from '../../../common/navigation/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'; @@ -32,8 +32,7 @@ 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 { 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'; @@ -41,6 +40,7 @@ import { useLatestVulnerabilitiesByResource } from '../hooks/use_latest_vulnerab import { EmptyState } from '../../../components/empty_state'; import { SeverityMap } from './severity_map'; import { VULNERABILITY_RESOURCE_COUNT } from './test_subjects'; +import { getVulnerabilitiesGridCellActions } from '../utils/get_vulnerabilities_grid_cell_actions'; const getDefaultQuery = ({ query, filters }: any): any => ({ query, @@ -84,114 +84,19 @@ export const VulnerabilitiesByResource = ({ dataView }: { dataView: DataView }) }); 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 ( - - { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field: columnId, - value, - negate: false, - }), - }); - }} - > - {FILTER_IN} - - - ); - }, - ({ Component, rowIndex, columnId }) => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const value = getColumnIdValue(rowIndexFromPage, columnId); - - if (!value) return null; - return ( - - { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field: columnId, - value, - negate: true, - }), - }); - }} - > - {FILTER_OUT} - - - ); - }, - ]; - - return getVulnerabilitiesByResourceColumnsGrid(cellActions); - }, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]); + if (!data?.page) { + return []; + } + return getVulnerabilitiesGridCellActions({ + columnGridFn: getVulnerabilitiesByResourceColumnsGrid, + columns: vulnerabilitiesByResourceColumns, + dataView, + pageSize, + data: data.page, + setUrlQuery, + filters: urlQuery.filters, + }); + }, [data, dataView, pageSize, setUrlQuery, urlQuery.filters]); const renderCellValue = useMemo(() => { const Cell: React.FC = ({ @@ -203,16 +108,26 @@ export const VulnerabilitiesByResource = ({ dataView }: { dataView: DataView }) const resourceVulnerabilityRow = data?.page[rowIndexFromPage]; if (isFetching) return null; - if (!resourceVulnerabilityRow?.['resource.id']) return null; + if (!resourceVulnerabilityRow?.resource?.id) return null; if (columnId === vulnerabilitiesByResourceColumns.resource_id) { - return <>{resourceVulnerabilityRow['resource.id']}; + return ( + + {resourceVulnerabilityRow?.resource?.id} + + ); } if (columnId === vulnerabilitiesByResourceColumns.resource_name) { - return <>{resourceVulnerabilityRow['resource.name']}; + return <>{resourceVulnerabilityRow?.resource?.name}; } if (columnId === vulnerabilitiesByResourceColumns.region) { - return <>{resourceVulnerabilityRow['cloud.region']}; + return <>{resourceVulnerabilityRow?.cloud?.region}; } if (columnId === vulnerabilitiesByResourceColumns.vulnerabilities_count) { return ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx index 0b08af66d6ee9..efe451ba97e54 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx @@ -189,7 +189,7 @@ export const VulnerabilityFindingFlyout = ({ -

-

+