[Cloud Security ] 12165 update UI handling of multiple CVEs and package fields (#216411)

## Summary

This PR updates the rendering of multi value fields - vulnerability.id,
package.name, package.version and package.fixed_version in the
vulnerabilities data-grid page and alerts insights vulnerabilities tab
data grid.
It also updates the rendering of package.* fields in the vulnerabilities
flyout and both flyout and data grids are re using the same kbn package
component to display it.


### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Make CVSS column to be displayed first in the data grid.
- [x] if there is a single CVE display its value across the data grid.
- [x] data-grid if there is more than a single CVE show it as
<first_cve> <+x more> badge indicating the number of CVES left. Clicking
on the badge should open a Popver where all CVEs are displayed as badges
- clicking on the value will add it to the search bar filters, each
batch should have a copy icon as well.
- [x] insights tab data-grid should have similar logic to display multi
value fields but without adding it to the filters logic since there are
no filters in that page.
- [x] logic of displaying multiple CVEs should be applied to
package.name, package.version and package.fixed_version fields in both
data grids.
- [x] arrays in package-related vulnerability fields are rendered
correctly in the flyout header and footer.
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Screen recording


https://github.com/user-attachments/assets/208f8445-83c1-4e8f-a490-85ec48830fae

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alex Prozorov 2025-04-08 10:22:57 +03:00 committed by GitHub
parent 9cc220ac52
commit 854bfc4964
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 817 additions and 129 deletions

View file

@ -21,3 +21,6 @@ export { getVulnerabilityStats, hasVulnerabilitiesData } from './src/utils/vulne
export { CVSScoreBadge, SeverityStatusBadge } from './src/components/vulnerability_badges';
export { getNormalizedSeverity } from './src/utils/get_normalized_severity';
export { createMisconfigurationFindingsQuery } from './src/utils/findings_query_builders';
export { ActionableBadge, type MultiValueCellAction } from './src/components/actionable_badge';
export { MultiValueCellPopover } from './src/components/multi_value_cell_popover';
export { findReferenceLink } from './src/utils/find_reference_link.util';

View file

@ -0,0 +1,160 @@
/*
* 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, { useState, useRef } from 'react';
import {
EuiBadge,
EuiText,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
EuiCopy,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { createPortal } from 'react-dom';
import { css } from '@emotion/react';
const copyItem = i18n.translate('securitySolutionPackages.csp.actionableBadge.copy', {
defaultMessage: 'Copy',
});
export interface MultiValueCellAction {
iconType: string;
onClick?: () => void;
ariaLabel: string;
title?: string;
}
interface ActionableBadgeProps {
item: string;
index: number;
actions?: MultiValueCellAction[];
}
export const ActionableBadge = ({ item, index, actions = [] }: ActionableBadgeProps) => {
const [showActions, setShowActions] = useState(false);
const { euiTheme } = useEuiTheme();
const buttonRef = useRef<HTMLDivElement | null>(null);
const iconsContainerRef = useRef<HTMLDivElement | null>(null);
const [buttonPosition, setButtonPosition] = useState({ bottom: 0, left: 0 });
const updatePosition = () => {
if (buttonRef.current && iconsContainerRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const myElement = iconsContainerRef.current.getBoundingClientRect();
setButtonPosition({
bottom: window.innerHeight - rect.top, // offset from the top of the badge
left: rect.right - myElement.width, // align with right edge
});
}
};
const buttonIconCss = css`
color: ${euiTheme.colors.plainLight};
inline-size: ${euiTheme.base}px;
block-size: ${euiTheme.base}px;
svg {
width: ${euiTheme.size.m};
height: ${euiTheme.size.m};
`;
React.useEffect(() => {
if (showActions) {
updatePosition();
}
}, [showActions]);
const handleMouseLeave = () => {
setShowActions(false);
};
const handleMouseEnter = () => {
setShowActions(true);
};
const actionButtons = createPortal(
<EuiFlexGroup
ref={iconsContainerRef}
gutterSize="xs"
alignItems="flexEnd"
onMouseEnter={handleMouseEnter}
responsive={false}
onMouseLeave={handleMouseLeave}
css={css`
position: fixed;
z-index: ${euiTheme.levels.modal};
background-color: ${euiTheme.colors.primary};
border-radius: ${euiTheme.size.xs};
padding: ${euiTheme.size.xxs};
`}
style={{
bottom: buttonPosition.bottom,
left: buttonPosition.left,
}}
>
{actions.map((action, actionIndex) => (
<EuiFlexItem grow={false} key={`${action.iconType}-${actionIndex}`}>
<EuiButtonIcon
css={buttonIconCss}
onClick={action.onClick}
iconType={action.iconType}
aria-label={action.ariaLabel}
title={action.title}
/>
</EuiFlexItem>
))}
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={item}>
{(copy) => (
<EuiButtonIcon
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
copy();
}}
iconType="copy"
css={buttonIconCss}
aria-label={copyItem}
title={copyItem}
/>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>,
document.body
);
return (
<div
ref={buttonRef}
css={css`
position: relative;
display: inline-block;
`}
>
{showActions && actionButtons}
<EuiBadge
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
color="hollow"
key={`${item}-${index}`}
data-test-subj={`multi-value-copy-badge-${index}`}
>
<EuiText
css={css`
text-overflow: ellipsis;
`}
size="m"
>
{item}
</EuiText>
</EuiBadge>
</div>
);
};

View file

@ -0,0 +1,98 @@
/*
* 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 { act } from 'react-dom/test-utils';
import { render, fireEvent, screen } from '@testing-library/react';
import { MultiValueCellPopover } from './multi_value_cell_popover';
import { EuiBadge, EuiText } from '@elastic/eui';
import { MULTI_VALUE_CELL_FIRST_ITEM_VALUE, MULTI_VALUE_CELL_MORE_BUTTON } from '../constants';
const RENDER_ITEM_TEST_ID = 'item-renderer-test-id';
// Mock EUI theme hook
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
useEuiTheme: () => ({ euiTheme: { size: { s: '8px' } } }),
}));
describe('MultiValueCellPopover', () => {
const mockObject = { id: '1' };
const defaultRenderItem = (item: string) => (
<EuiBadge data-test-subj={RENDER_ITEM_TEST_ID}>{item}</EuiBadge>
);
it('renders single item without badge', () => {
render(
<MultiValueCellPopover
items={['item1']}
field="test"
object={mockObject}
renderItem={defaultRenderItem}
/>
);
expect(screen.getByTestId(MULTI_VALUE_CELL_FIRST_ITEM_VALUE)).toBeInTheDocument();
expect(screen.getByTestId(MULTI_VALUE_CELL_FIRST_ITEM_VALUE)).toHaveTextContent('item1');
expect(screen.queryByTestId(MULTI_VALUE_CELL_MORE_BUTTON)).not.toBeInTheDocument();
});
it('renders multiple items with badge', () => {
render(
<MultiValueCellPopover
items={['item1', 'item2', 'item3']}
field="test"
object={mockObject}
renderItem={defaultRenderItem}
/>
);
expect(screen.getByTestId(MULTI_VALUE_CELL_FIRST_ITEM_VALUE)).toBeInTheDocument();
expect(screen.getByTestId(MULTI_VALUE_CELL_FIRST_ITEM_VALUE)).toHaveTextContent('item1');
expect(screen.getByTestId(MULTI_VALUE_CELL_MORE_BUTTON)).toBeInTheDocument();
expect(screen.getByTestId(MULTI_VALUE_CELL_MORE_BUTTON)).toHaveTextContent('+ 2');
});
it('opens popover on badge click', async () => {
render(
<MultiValueCellPopover
items={['item1', 'item2']}
field="test"
object={mockObject}
renderItem={defaultRenderItem}
/>
);
const badge = screen.getByText('+ 1');
act(() => {
fireEvent.click(badge);
});
expect(screen.getAllByTestId(RENDER_ITEM_TEST_ID)).toHaveLength(2);
});
it('uses custom firstItemRenderer when provided', () => {
const customRenderTestId = 'custom-renderer-test-id';
const customRenderer = (item: string) => (
<EuiText data-test-subj={customRenderTestId}>{`Custom ${item}`}</EuiText>
);
render(
<MultiValueCellPopover
items={['item1']}
field="test"
object={mockObject}
renderItem={defaultRenderItem}
firstItemRenderer={customRenderer}
/>
);
expect(screen.getByTestId(customRenderTestId)).toBeInTheDocument();
expect(screen.getByTestId(customRenderTestId)).toHaveTextContent('Custom item1');
});
});

View file

@ -0,0 +1,106 @@
/*
* 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, { useState } from 'react';
import {
EuiPopover,
EuiBadge,
useEuiTheme,
EuiFlexGroup,
EuiFlexItem,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { MULTI_VALUE_CELL_FIRST_ITEM_VALUE, MULTI_VALUE_CELL_MORE_BUTTON } from '../constants';
const getShowMoreAriaLabel = (field: string, number: number) =>
i18n.translate('securitySolutionPackages.csp.multiValueField.showMore', {
defaultMessage: 'Field {field} has {number, plural, one {# item} other {#items}}',
values: { field, number },
});
interface MultiValueCellPopoverProps<T, K = string> {
items: K[];
field: string;
object: T;
renderItem: (item: K, i: number, field: string, object: T) => React.ReactNode;
firstItemRenderer?: (item: K) => React.ReactNode;
}
const MultiValueCellPopoverComponent = <T, K = string>({
items,
field,
object,
renderItem,
firstItemRenderer,
}: MultiValueCellPopoverProps<T, K>) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const { euiTheme } = useEuiTheme();
const onButtonClick = () => setIsPopoverOpen(!isPopoverOpen);
const closePopover = () => setIsPopoverOpen(false);
return (
<EuiFlexGroup responsive={true} gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
{firstItemRenderer ? (
firstItemRenderer(items[0])
) : (
<EuiText size="s" data-test-subj={MULTI_VALUE_CELL_FIRST_ITEM_VALUE}>
{String(items[0])}
</EuiText>
)}
</EuiFlexItem>
{items.length > 1 && (
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiBadge
data-test-subj={MULTI_VALUE_CELL_MORE_BUTTON}
color="hollow"
onClick={onButtonClick}
onClickAriaLabel={getShowMoreAriaLabel(field, items.length - 1)}
>
{`+ ${items.length - 1}`}
</EuiBadge>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="s"
repositionOnScroll
panelStyle={{ minWidth: 'min-content' }}
>
<EuiFlexGroup
direction="column"
wrap={false}
responsive={false}
gutterSize="xs"
justifyContent="flexStart"
alignItems="flexStart"
css={css`
max-height: 230px;
overflow-y: auto;
max-width: min-content;
width: min-content;
min-width: unset;
padding-right: ${euiTheme.size.s};
`}
>
{items.map((item, index) => renderItem(item, index, field, object))}
</EuiFlexGroup>
</EuiPopover>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
const MemoizedMultiValueCellPopoverComponent = React.memo(MultiValueCellPopoverComponent);
MemoizedMultiValueCellPopoverComponent.displayName = 'MultiValueCellPopover';
export const MultiValueCellPopover =
MemoizedMultiValueCellPopoverComponent as typeof MultiValueCellPopoverComponent;

View file

@ -6,3 +6,4 @@
*/
export * from './integration-vendors';
export * from './test_subjects';

View file

@ -0,0 +1,9 @@
/*
* 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 MULTI_VALUE_CELL_FIRST_ITEM_VALUE = 'multi_value_cell_first_item_value';
export const MULTI_VALUE_CELL_MORE_BUTTON = 'multi_value_cell_more_button';

View file

@ -30,7 +30,7 @@ export enum VULNERABILITY {
TITLE = 'vulnerability.title',
ID = 'vulnerability.id',
SEVERITY = 'vulnerability.severity',
PACKAGE_NAME = 'vulnerability.package.name',
PACKAGE_NAME = 'package.name',
}
type LatestFindingsRequest = IKibanaSearchRequest<SearchRequest>;

View file

@ -202,7 +202,7 @@ export const VULNERABILITY_GROUPING_OPTIONS = {
RESOURCE_ID: VULNERABILITY_FIELDS.RESOURCE_ID,
CLOUD_ACCOUNT_NAME: VULNERABILITY_FIELDS.CLOUD_ACCOUNT_NAME,
CVE: VULNERABILITY_FIELDS.VULNERABILITY_ID,
};
} as const;
/*
* ECS schema unique field to describe the event

View file

@ -25,3 +25,9 @@ export interface FindingsBaseESQueryConfig {
}
export type Sort<T> = NonNullable<Criteria<T>['sort']>;
export type VulnerabilityGroupingMultiValueOptions =
| 'vulnerability.id'
| 'package.name'
| 'package.version'
| 'package.fixed_version';

View file

@ -135,6 +135,7 @@ export const CloudSecurityDataTable = ({
columnsLocalStorageKey,
defaultColumns.map((c) => c.id)
);
const [persistedSettings, setPersistedSettings] = useLocalStorage<UnifiedDataTableSettings>(
`${columnsLocalStorageKey}:settings`,
{

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButtonIcon, EuiCopy } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const COPY_ARIA_LABEL = i18n.translate('xpack.csp.clipboard.copy', {
defaultMessage: 'Copy',
});
export const CopyButton: React.FC<{ copyText: string }> = ({ copyText }) => (
<EuiCopy textToCopy={copyText}>
{(copy) => (
<EuiButtonIcon color="text" aria-label={COPY_ARIA_LABEL} iconType="copy" onClick={copy} />
)}
</EuiCopy>
);

View file

@ -6,25 +6,9 @@
*/
import React from 'react';
import { EuiDescriptionList, useEuiTheme, EuiIcon, EuiCopy } from '@elastic/eui';
import { EuiDescriptionList, useEuiTheme } from '@elastic/eui';
import type { EuiDescriptionListProps } from '@elastic/eui';
import { css } from '@emotion/react';
const CopyButton = ({ copyText }: { copyText: string }) => (
<EuiCopy textToCopy={copyText}>
{(copy) => (
<EuiIcon
css={css`
:hover {
cursor: pointer;
}
`}
onClick={copy}
type="copy"
/>
)}
</EuiCopy>
);
import { CopyButton } from './copy_button';
const getModifiedTitlesListItems = (listItems?: EuiDescriptionListProps['listItems']) =>
listItems
@ -54,7 +38,6 @@ export const CspInlineDescriptionList = ({
}) => {
const { euiTheme } = useEuiTheme();
const modifiedTitlesListItems = getModifiedTitlesListItems(listItems);
return (
<EuiDescriptionList
data-test-subj={testId}
@ -62,15 +45,17 @@ export const CspInlineDescriptionList = ({
titleProps={{
style: {
background: 'initial',
color: euiTheme.colors.subduedText,
color: euiTheme.colors.textSubdued,
fontSize,
paddingRight: 0,
paddingInline: 0,
marginInline: 'unset',
marginInlineEnd: euiTheme.size.xs,
},
}}
descriptionProps={{
style: {
color: euiTheme.colors.subduedText,
color: euiTheme.colors.textSubdued,
marginRight: euiTheme.size.xs,
fontSize,
},

View file

@ -41,9 +41,9 @@ export const getDefaultQuery = ({
});
export const defaultColumns: CloudSecurityDefaultColumn[] = [
{ id: VULNERABILITY_FIELDS.VULNERABILITY_TITLE, width: 160 },
{ id: VULNERABILITY_FIELDS.VULNERABILITY_ID, width: 130 },
{ id: VULNERABILITY_FIELDS.SCORE_BASE, width: 80 },
{ id: VULNERABILITY_FIELDS.VULNERABILITY_TITLE, width: 160 },
{ id: VULNERABILITY_FIELDS.VULNERABILITY_ID, width: 180 },
{ id: VULNERABILITY_FIELDS.RESOURCE_NAME },
{ id: VULNERABILITY_FIELDS.RESOURCE_ID },
{ id: VULNERABILITY_FIELDS.SEVERITY, width: 100 },

View file

@ -5,19 +5,25 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { get } from 'lodash/fp';
import { DataTableRecord } from '@kbn/discover-utils/types';
import { i18n } from '@kbn/i18n';
import { EuiDataGridCellValueElementProps, EuiSpacer } from '@elastic/eui';
import { EuiDataGridCellValueElementProps, EuiSpacer, EuiText } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { HttpSetup } from '@kbn/core-http-browser';
import { generateFilters } from '@kbn/data-plugin/public';
import type { CspVulnerabilityFinding } from '@kbn/cloud-security-posture-common/schema/vulnerabilities/latest';
import {
ActionableBadge,
CVSScoreBadge,
SeverityStatusBadge,
getNormalizedSeverity,
MultiValueCellAction,
MultiValueCellPopover,
findReferenceLink,
} from '@kbn/cloud-security-posture';
import { getVendorName } from '@kbn/cloud-security-posture/src/utils/get_vendor_name';
import { HttpSetup } from '@kbn/core/public';
import { useLatestVulnerabilitiesTable } from './hooks/use_latest_vulnerabilities_table';
import { LATEST_VULNERABILITIES_TABLE } from './test_subjects';
import { getDefaultQuery, defaultColumns } from './constants';
@ -26,6 +32,15 @@ import { ErrorCallout } from '../configurations/layout/error_callout';
import { createDetectionRuleFromVulnerabilityFinding } from './utils/create_detection_rule_from_vulnerability';
import { vulnerabilitiesTableFieldLabels } from './vulnerabilities_table_field_labels';
import { VulnerabilityCloudSecurityDataTable } from '../../components/cloud_security_data_table/vulnerability_security_data_table';
import { FindingsBaseURLQuery } from '../../common/types';
import { useKibana } from '../../common/hooks/use_kibana';
import { useDataViewContext } from '../../common/contexts/data_view_context';
import { usePersistedQuery } from '../../common/hooks/use_cloud_posture_data_table';
import { useUrlQuery } from '../../common/hooks/use_url_query';
import { EMPTY_VALUE } from '../configurations/findings_flyout/findings_flyout';
import { AddFieldFilterHandler } from './types';
type URLQuery = FindingsBaseURLQuery & Record<string, any>;
interface LatestVulnerabilitiesTableProps {
groupSelectorComponent?: JSX.Element;
@ -79,71 +94,6 @@ const title = i18n.translate('xpack.csp.findings.latestVulnerabilities.tableRowT
defaultMessage: 'Vulnerabilities',
});
const customCellRenderer = (rows: DataTableRecord[]) => ({
'vulnerability.score.base': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => (
<CVSScoreBadge
score={finding.vulnerability?.score?.base}
version={finding.vulnerability?.score?.version}
/>
)}
</CspVulnerabilityFindingRenderer>
),
'vulnerability.severity': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => (
<SeverityStatusBadge severity={getNormalizedSeverity(finding.vulnerability.severity)} />
)}
</CspVulnerabilityFindingRenderer>
),
'observer.vendor': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => <>{getVendorName(finding) || '-'}</>}
</CspVulnerabilityFindingRenderer>
),
'vulnerability.id': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => {
if (Array.isArray(finding.vulnerability?.id)) {
return <>{finding.vulnerability.id.join(', ')}</>;
}
return <>{finding.vulnerability?.id || '-'}</>;
}}
</CspVulnerabilityFindingRenderer>
),
'package.name': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => {
if (Array.isArray(finding.package.name)) {
return <>{finding.package.name.join(', ')}</>;
}
return <>{finding.package.name || '-'}</>;
}}
</CspVulnerabilityFindingRenderer>
),
'package.version': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => {
if (Array.isArray(finding.package.version)) {
return <>{finding.package.version.join(', ')}</>;
}
return <>{finding.package.version || '-'}</>;
}}
</CspVulnerabilityFindingRenderer>
),
'package.fixed_version': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => {
if (Array.isArray(finding.package.fixed_version)) {
return <>{finding.package.fixed_version.join(', ')}</>;
}
return <>{finding.package.fixed_version || '-'}</>;
}}
</CspVulnerabilityFindingRenderer>
),
});
export const LatestVulnerabilitiesTable = ({
groupSelectorComponent,
height,
@ -163,6 +113,138 @@ export const LatestVulnerabilitiesTable = ({
createDetectionRuleFromVulnerabilityFinding(http, vulnerabilityFinding);
};
const { data } = useKibana().services;
const { filterManager } = data.query;
const { dataView } = useDataViewContext();
const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery);
const { setUrlQuery } = useUrlQuery<URLQuery>(getPersistedDefaultQuery);
const onAddFilter = useCallback<AddFieldFilterHandler>(
(clickedField, value, operation) => {
const newFilters = generateFilters(filterManager, clickedField, value, operation, dataView);
filterManager.addFilters(newFilters);
setUrlQuery({
filters: filterManager.getFilters(),
});
},
[dataView, filterManager, setUrlQuery]
);
const renderActionableBadge = useCallback(
(item: string, i: number, field: string, object: CspVulnerabilityFinding) => {
const references = Array.isArray(object.vulnerability.reference)
? object.vulnerability.reference
: [object.vulnerability.reference];
const actions: MultiValueCellAction[] = [
{
onClick: () => onAddFilter(field, item, '+'),
iconType: 'plusInCircle',
ariaLabel: i18n.translate('xpack.csp.latestVulnerabilities.table.addFilter', {
defaultMessage: 'Add filter',
}),
title: i18n.translate('xpack.csp.latestVulnerabilities.table.filterForTitle', {
defaultMessage: 'Filter for this {value}',
values: { value: field },
}),
},
{
onClick: () => onAddFilter(field, item, '-'),
iconType: 'minusInCircle',
ariaLabel: i18n.translate('xpack.csp.latestVulnerabilities.table.removeFilter', {
defaultMessage: 'Remove filter',
}),
title: i18n.translate('xpack.csp.latestVulnerabilities.table.filterOutTitle', {
defaultMessage: 'Filter out this {value}',
values: { value: field },
}),
},
...(field === 'vulnerability.id' && findReferenceLink(references, item)
? [
{
onClick: () => window.open(findReferenceLink(references, item)!, '_blank'),
iconType: 'popout',
ariaLabel: i18n.translate('xpack.csp.latestVulnerabilities.table.openUrlInWindow', {
defaultMessage: 'Open URL in window',
}),
title: i18n.translate('xpack.csp.latestVulnerabilities.table.openUrlInWindow', {
defaultMessage: 'Open URL in window',
}),
},
]
: []),
];
return <ActionableBadge key={`${item}-${i}`} item={item} index={i} actions={actions} />;
},
[onAddFilter]
);
const renderMultiValueCell = (field: string, finding: CspVulnerabilityFinding) => {
const value = get(field, finding);
if (!Array.isArray(value)) {
return <EuiText size="s">{value || EMPTY_VALUE}</EuiText>;
}
return (
<MultiValueCellPopover<CspVulnerabilityFinding>
items={value}
field={field}
object={finding}
renderItem={renderActionableBadge}
firstItemRenderer={(item) => <EuiText size="s">{item}</EuiText>}
/>
);
};
const customCellRenderer = (tableRows: DataTableRecord[]) => {
return {
'vulnerability.score.base': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={tableRows[rowIndex]}>
{({ finding }) => (
<CVSScoreBadge
score={finding.vulnerability?.score?.base}
version={finding.vulnerability?.score?.version}
/>
)}
</CspVulnerabilityFindingRenderer>
),
'vulnerability.severity': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={tableRows[rowIndex]}>
{({ finding }) => (
<SeverityStatusBadge severity={getNormalizedSeverity(finding.vulnerability.severity)} />
)}
</CspVulnerabilityFindingRenderer>
),
'observer.vendor': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={tableRows[rowIndex]}>
{({ finding }) => <>{getVendorName(finding) || '-'}</>}
</CspVulnerabilityFindingRenderer>
),
'vulnerability.id': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={tableRows[rowIndex]}>
{({ finding }) => renderMultiValueCell('vulnerability.id', finding)}
</CspVulnerabilityFindingRenderer>
),
'package.name': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => renderMultiValueCell('package.name', finding)}
</CspVulnerabilityFindingRenderer>
),
'package.version': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => renderMultiValueCell('package.version', finding)}
</CspVulnerabilityFindingRenderer>
),
'package.fixed_version': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspVulnerabilityFindingRenderer row={rows[rowIndex]}>
{({ finding }) => renderMultiValueCell('package.fixed_version', finding)}
</CspVulnerabilityFindingRenderer>
),
};
};
return (
<>
{error ? (

View file

@ -19,3 +19,5 @@ export interface Vector {
vector: string;
score: number | undefined;
}
export type AddFieldFilterHandler = (field: string, value: unknown, type: '+' | '-') => void;

View file

@ -160,9 +160,8 @@ describe('<VulnerabilityFindingFlyout/>', () => {
getAllByText(mockVulnerabilityHit.vulnerability?.cvss?.redhat?.V3Score?.toString() as string);
getAllByText(mockVulnerabilityHit.vulnerability?.cvss?.ghsa?.V3Vector?.toString() as string);
getAllByText(mockVulnerabilityHit.vulnerability?.cvss?.ghsa?.V3Score?.toString() as string);
getByText(
`${mockVulnerabilityHit.package.name} ${mockVulnerabilityHit.package.fixed_version}`
);
getByText(mockVulnerabilityHit.package.name as string);
getByText(mockVulnerabilityHit.package.fixed_version as string);
});
it('Overview Tab with Wiz vulnerability missing fields', () => {

View file

@ -31,7 +31,13 @@ import { HttpSetup } from '@kbn/core-http-browser';
import type { CspVulnerabilityFinding } from '@kbn/cloud-security-posture-common/schema/vulnerabilities/latest';
import { isNativeCspFinding } from '@kbn/cloud-security-posture/src/utils/is_native_csp_finding';
import { truthy } from '@kbn/cloud-security-posture/src/utils/helpers';
import { SeverityStatusBadge, getNormalizedSeverity } from '@kbn/cloud-security-posture';
import {
MultiValueCellPopover,
SeverityStatusBadge,
findReferenceLink,
getNormalizedSeverity,
} from '@kbn/cloud-security-posture';
import { CopyButton } from '../../../components/copy_button';
import { TakeAction } from '../../../components/take_action';
import { CspInlineDescriptionList } from '../../../components/csp_inline_description_list';
import { VulnerabilityOverviewTab } from './vulnerability_overview_tab';
@ -47,12 +53,31 @@ import {
import { VulnerabilityTableTab } from './vulnerability_table_tab';
import { createDetectionRuleFromVulnerabilityFinding } from '../utils/create_detection_rule_from_vulnerability';
import { MissingFieldsCallout } from '../../configurations/findings_flyout/findings_flyout';
import { findReferenceLink } from '../utils/find_reference_link.util';
const overviewTabId = 'vuln-flyout-overview-tab';
const tableTabId = 'vuln-flyout-table-tab';
const jsonTabId = 'vuln-flyout-json-tab';
const renderFinding = (item: string, i: number, field: string, object: CspVulnerabilityFinding) => (
<EuiFlexGroup gutterSize="xs" direction="row" justifyContent="flexStart" alignItems="center">
<EuiFlexItem>
<EuiText
size="s"
css={css`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`}
>
{item}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CopyButton copyText={item} />
</EuiFlexItem>
</EuiFlexGroup>
);
const getFlyoutDescriptionList = (
vulnerabilityRecord: CspVulnerabilityFinding
): EuiDescriptionListProps['listItems'] =>
@ -76,14 +101,44 @@ const getFlyoutDescriptionList = (
'xpack.csp.vulnerabilities.vulnerabilitiesFindingFlyout.flyoutDescriptionList.packageTitle',
{ defaultMessage: 'Package' }
),
description: vulnerabilityRecord.package.name,
description: Array.isArray(vulnerabilityRecord.package?.name) ? (
<div
css={css`
display: inline-block;
`}
>
<MultiValueCellPopover<CspVulnerabilityFinding>
items={vulnerabilityRecord.package?.name}
field={'package.name'}
object={vulnerabilityRecord}
renderItem={renderFinding}
/>
</div>
) : (
vulnerabilityRecord.package?.name
),
},
vulnerabilityRecord.package?.version && {
title: i18n.translate(
'xpack.csp.vulnerabilities.vulnerabilitiesFindingFlyout.flyoutDescriptionList.versionTitle',
{ defaultMessage: 'Version' }
),
description: vulnerabilityRecord.package.version,
description: Array.isArray(vulnerabilityRecord.package?.version) ? (
<div
css={css`
display: inline-block;
`}
>
<MultiValueCellPopover<CspVulnerabilityFinding>
items={vulnerabilityRecord.package?.version}
field={'package.version'}
object={vulnerabilityRecord}
renderItem={renderFinding}
/>
</div>
) : (
vulnerabilityRecord.package?.version
),
},
].filter(truthy);

View file

@ -29,8 +29,13 @@ import {
VULNERABILITIES_FLYOUT_VISITS,
uiMetricService,
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { CVSScoreBadge } from '@kbn/cloud-security-posture';
import {
CVSScoreBadge,
MultiValueCellPopover,
findReferenceLink,
} from '@kbn/cloud-security-posture';
import { getVendorName } from '@kbn/cloud-security-posture/src/utils/get_vendor_name';
import { get } from 'lodash/fp';
import { CspFlyoutMarkdown } from '../../configurations/findings_flyout/findings_flyout';
import { NvdLogo } from '../../../assets/icons/nvd_logo_svg';
import { CVSScoreProps, Vendor } from '../types';
@ -47,7 +52,7 @@ import {
} from '../test_subjects';
import redhatLogo from '../../../assets/icons/redhat_logo.svg';
import { VulnerabilityDetectionRuleCounter } from './vulnerability_detection_rule_counter';
import { findReferenceLink } from '../utils/find_reference_link.util';
import { CopyButton } from '../../../components/copy_button';
const cvssVendors: Record<string, Vendor> = {
nvd: 'NVD',
@ -217,9 +222,109 @@ export const VulnerabilityOverviewTab = ({ vulnerabilityRecord }: VulnerabilityT
}
);
const fixesDisplayText = vulnerabilityRecord?.package?.fixed_version
? `${vulnerabilityRecord?.package?.name} ${vulnerabilityRecord?.package?.fixed_version}`
: emptyFixesMessageState;
const renderFixedBySection = () => {
const renderFinding = (
item: string,
i: number,
field: string,
object: CspVulnerabilityFinding
) => (
<EuiFlexGroup gutterSize="xs" direction="row" justifyContent="flexStart" alignItems="center">
<EuiFlexItem>
<EuiText
size="s"
css={css`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`}
>
{item}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CopyButton copyText={item} />
</EuiFlexItem>
</EuiFlexGroup>
);
const renderPackageField = (field: string) => {
const value = get(field, vulnerabilityRecord);
if (!value) {
return <EuiText size="xs">{EMPTY_VALUE}</EuiText>;
}
return Array.isArray(value) ? (
<MultiValueCellPopover<CspVulnerabilityFinding>
items={value}
field={`package.${field}`}
object={vulnerabilityRecord}
renderItem={renderFinding}
/>
) : (
value
);
};
if (!vulnerabilityRecord?.package?.fixed_version) {
return <EuiText>{emptyFixesMessageState}</EuiText>;
}
return (
<EuiFlexGroup responsive={false} gutterSize="xs" alignItems="flexStart" direction="column">
<EuiFlexItem>
<EuiFlexGroup
responsive={false}
gutterSize="xs"
justifyContent="flexStart"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiText
size="xs"
css={css`
font-weight: ${euiTheme.font.weight.bold};
`}
>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilitiesFindingFlyout.flyoutDescriptionList.packageTitle"
defaultMessage="Package"
/>
:
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>{renderPackageField('package.name')}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup
responsive={false}
gutterSize="xs"
justifyContent="flexStart"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiText
size="xs"
css={css`
font-weight: ${euiTheme.font.weight.bold};
`}
>
<FormattedMessage
css={css`
font-weight: ${euiTheme.font.weight.medium};
`}
id="xpack.csp.vulnerabilities.vulnerabilitiesFindingFlyout.flyoutDescriptionList.versionTitle"
defaultMessage="Version"
/>
:
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>{renderPackageField('package.fixed_version')}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const cvssScores = vulnerability?.cvss
? Object.entries<VectorScoreBase>(vulnerability.cvss).map(
@ -436,7 +541,7 @@ export const VulnerabilityOverviewTab = ({ vulnerabilityRecord }: VulnerabilityT
defaultMessage="Fixed by"
/>
</h4>
<EuiText>{fixesDisplayText || EMPTY_VALUE}</EuiText>
{renderFixedBySection()}
</EuiFlexItem>
<EuiHorizontalRule css={horizontalStyle} />

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { memo, useEffect, useState } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import type { Criteria, EuiBasicTableColumn, EuiTableSortingType } from '@elastic/eui';
import { EuiSpacer, EuiPanel, EuiText, EuiBasicTable, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -19,11 +19,15 @@ import {
useVulnerabilitiesFindings,
VULNERABILITY,
} from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_findings';
import type { MultiValueCellAction } from '@kbn/cloud-security-posture';
import {
getVulnerabilityStats,
CVSScoreBadge,
SeverityStatusBadge,
getNormalizedSeverity,
ActionableBadge,
MultiValueCellPopover,
findReferenceLink,
} from '@kbn/cloud-security-posture';
import {
ENTITY_FLYOUT_EXPAND_VULNERABILITY_VIEW_VISITS,
@ -35,6 +39,7 @@ import { SecurityPageName } from '@kbn/deeplinks-security';
import { useGetNavigationUrlParams } from '@kbn/cloud-security-posture/src/hooks/use_get_navigation_url_params';
import { useGetSeverityStatusColor } from '@kbn/cloud-security-posture/src/hooks/use_get_severity_status_color';
import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities';
import { get } from 'lodash/fp';
import { EntityIdentifierFields } from '../../../../common/entity_analytics/types';
import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
@ -163,6 +168,59 @@ export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: str
currentFilter
);
const renderItem = useCallback(
(item: string, i: number, field: string, object: VulnerabilitiesFindingDetailFields) => {
const references = Array.isArray(object.vulnerability.reference)
? object.vulnerability.reference
: [object.vulnerability.reference];
const url = findReferenceLink(references, item);
const actions: MultiValueCellAction[] = [
...(field === 'vulnerability.id' && url
? [
{
onClick: () => window.open(url, '_blank'),
iconType: 'popout',
ariaLabel: i18n.translate(
'xpack.securitySolution.vulnerabilities.findingsDetailsTable.openUrlInWindow',
{
defaultMessage: 'Open URL in window',
}
),
title: i18n.translate(
'xpack.securitySolution.vulnerabilities.findingsDetailsTable.openUrlInWindow',
{
defaultMessage: 'Open URL in window',
}
),
},
]
: []),
];
return <ActionableBadge key={`${item}-${i}`} item={item} index={i} actions={actions} />;
},
[]
);
const renderMultiValueCell = (field: string, finding: VulnerabilitiesFindingDetailFields) => {
const cellValue = get(field, finding);
if (!Array.isArray(cellValue)) {
return <EuiText size="s">{cellValue || EMPTY_VALUE}</EuiText>;
}
return (
<MultiValueCellPopover<VulnerabilitiesFindingDetailFields>
items={cellValue}
field={field}
object={finding}
renderItem={renderItem}
firstItemRenderer={(item) => <EuiText size="s">{item}</EuiText>}
/>
);
};
const columns: Array<EuiBasicTableColumn<VulnerabilitiesFindingDetailFields>> = [
{
field: 'vulnerability',
@ -187,6 +245,20 @@ export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: str
</SecuritySolutionLinkAnchor>
),
},
{
field: 'score',
render: (score: { version?: string; base?: number }) => (
<EuiText size="s">
<CVSScoreBadge version={score?.version} score={score?.base} />
</EuiText>
),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName',
{ defaultMessage: 'CVSS' }
),
width: '10%',
sortable: true,
},
{
field: VULNERABILITY.TITLE,
render: (title: string) => {
@ -205,13 +277,8 @@ export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: str
},
{
field: VULNERABILITY.ID,
render: (id: string) => {
if (Array.isArray(id)) {
return <EuiText size="s">{id.join(', ')}</EuiText>;
}
return <EuiText size="s">{id || EMPTY_VALUE}</EuiText>;
},
render: (id: string, finding: VulnerabilitiesFindingDetailFields) =>
renderMultiValueCell(VULNERABILITY.ID, finding),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.vulnerabilityIdColumnName',
{ defaultMessage: 'CVE ID' }
@ -219,20 +286,6 @@ export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: str
width: '20%',
sortable: true,
},
{
field: 'score',
render: (score: { version?: string; base?: number }) => (
<EuiText size="s">
<CVSScoreBadge version={score?.version} score={score?.base} />
</EuiText>
),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName',
{ defaultMessage: 'CVSS' }
),
width: '10%',
sortable: true,
},
{
field: VULNERABILITY.SEVERITY,
render: (severity: string) => (
@ -251,7 +304,8 @@ export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: str
},
{
field: VULNERABILITY.PACKAGE_NAME,
render: (packageName: string) => <EuiText size="s">{packageName}</EuiText>,
render: (packageName: string, finding: VulnerabilitiesFindingDetailFields) =>
renderMultiValueCell(VULNERABILITY.PACKAGE_NAME, finding),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName',
{ defaultMessage: 'Package' }