[Cloud Security] Allow open Vulnerability Flyout from Contextual Flyout (#221219)

## Summary

Users now are able to open Vulnerability Findings Flyout via Contextual
Flyout

* This PR also removes the Pivoting from title for both Misconfiguration
and Vulnerabilities Flyout on Alerts page



https://github.com/user-attachments/assets/6aa20a10-ae98-4856-b871-90acfca4aeb1
This commit is contained in:
Rickyanto Ang 2025-05-28 17:48:22 -07:00 committed by GitHub
parent 4c89a9ac50
commit 8c811f5e0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 505 additions and 402 deletions

View file

@ -244,7 +244,9 @@ describe('test helper methods', () => {
},
{
bool: {
should: [{ term: { 'some.field': 'some.status' } }],
should: [
{ term: { 'some.field': { value: 'some.status', case_insensitive: true } } },
],
minimum_should_match: 1,
},
},
@ -274,7 +276,9 @@ describe('test helper methods', () => {
},
{
bool: {
should: [{ term: { 'result.evaluation': 'pass' } }],
should: [
{ term: { 'result.evaluation': { value: 'pass', case_insensitive: true } } },
],
minimum_should_match: 1,
},
},
@ -303,7 +307,9 @@ describe('test helper methods', () => {
},
{
bool: {
should: [{ term: { 'vulnerability.severity': 'low' } }],
should: [
{ term: { 'vulnerability.severity': { value: 'low', case_insensitive: true } } },
],
minimum_should_match: 1,
},
},

View file

@ -84,7 +84,10 @@ export const buildGenericEntityFlyoutPreviewQuery = (
should: [
{
term: {
[queryField]: status,
[queryField]: {
value: status,
case_insensitive: true,
},
},
},
],

View file

@ -53,7 +53,7 @@ export interface VulnerabilitiesFindingTableDetailsFields {
}
export type VulnerabilitiesFindingDetailFields = Pick<Vulnerability, 'score'> &
Pick<CspVulnerabilityFinding, 'vulnerability' | 'resource'> &
Pick<CspVulnerabilityFinding, 'vulnerability' | 'resource' | 'event'> &
VulnerabilitiesFindingTableDetailsFields;
interface FindingsAggs {
@ -91,6 +91,7 @@ export const useVulnerabilitiesFindings = (options: UseCspOptions) => {
[VULNERABILITY.ID]: finding._source?.vulnerability?.id,
[VULNERABILITY.SEVERITY]: finding._source?.vulnerability?.severity,
[VULNERABILITY.PACKAGE_NAME]: finding._source?.package?.name,
event: finding._source?.event,
})) as VulnerabilitiesFindingDetailFields[],
};
},

View file

@ -85,7 +85,7 @@ export interface FindingsAggs {
count: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
}
interface BaseFlyoutProps {
interface BaseMisconfigurationFlyoutProps {
ruleId: string;
resourceId: string;
}
@ -105,12 +105,12 @@ interface NonPreviewModeProps {
}
export type FindingsMisconfigurationPanelExpandableFlyoutPropsNonPreview = FlyoutPanelProps & {
id: 'findings-misconfiguration-panel';
params: BaseFlyoutProps & NonPreviewModeProps;
params: BaseMisconfigurationFlyoutProps & NonPreviewModeProps;
};
export type FindingsMisconfigurationPanelExpandableFlyoutPropsPreview = FlyoutPanelProps & {
id: 'findings-misconfiguration-panel-preview';
params: BaseFlyoutProps & PreviewModeProps;
params: BaseMisconfigurationFlyoutProps & PreviewModeProps;
};
export type FindingsMisconfigurationPanelExpandableFlyoutProps =
@ -143,10 +143,6 @@ export interface FindingVulnerabilityFlyoutProps extends Record<string, unknown>
packageVersion: string | string[];
eventId: string;
}
export interface FindingVulnerabilityPanelExpandableFlyoutProps extends FlyoutPanelProps {
key: 'findings-vulnerability-panel';
params: FindingVulnerabilityFlyoutProps;
}
export interface FindingsVulnerabilityFlyoutHeaderProps {
finding: CspVulnerabilityFinding;
@ -154,6 +150,7 @@ export interface FindingsVulnerabilityFlyoutHeaderProps {
export interface FindingsVulnerabilityFlyoutContentProps {
finding: CspVulnerabilityFinding;
isPreviewMode?: boolean;
}
export interface FindingsVulnerabilityFlyoutFooterProps {
@ -163,4 +160,27 @@ export interface FindingsVulnerabilityFlyoutFooterProps {
export interface FindingVulnerabilityFullFlyoutContentProps {
finding: CspVulnerabilityFinding;
createRuleFn: (http: HttpSetup) => Promise<RuleResponse>;
isPreviewMode?: boolean;
}
interface BaseVulnerabilityFlyoutProps {
vulnerabilityId: string | string[];
resourceId: string;
packageName: string | string[];
packageVersion: string | string[];
eventId: string;
}
export type FindingsVulnerabilityPanelExpandableFlyoutPropsNonPreview = FlyoutPanelProps & {
id: 'findings-vulnerability-panel';
params: BaseVulnerabilityFlyoutProps & NonPreviewModeProps;
};
export type FindingsVulnerabilityPanelExpandableFlyoutPropsPreview = FlyoutPanelProps & {
id: 'findings-vulnerability-panel-preview';
params: BaseVulnerabilityFlyoutProps & PreviewModeProps;
};
export type FindingsVulnerabilityPanelExpandableFlyoutProps =
| FindingsVulnerabilityPanelExpandableFlyoutPropsNonPreview
| FindingsVulnerabilityPanelExpandableFlyoutPropsPreview;

View file

@ -145,7 +145,9 @@ const getDetailsList = (
<>
<EuiFlexGroup direction="row" gutterSize="m">
<EuiFlexItem>
<b>Rule Description</b>
{i18n.translate('xpack.csp.findings.findingsFlyout.overviewTab.ruleDescription', {
defaultMessage: 'Rule Description',
})}
</EuiFlexItem>
<EuiFlexItem>
<EuiLink href={ruleFlyoutLink} target="_blank" css={{ textAlign: 'right' }}>

View file

@ -17,7 +17,6 @@ import {
VULNERABILITY_HEADER_CVE_BADGE,
VULNERABILITY_HEADER_ID,
VULNERABILITY_HEADER_REFERENCE_LINK,
VULNERABILITY_HEADER_TITLE,
VULNERABILITY_OVERVIEW_TAB_ID_LESS_BTN,
VULNERABILITY_OVERVIEW_TAB_ID_MORE_BTN,
VULNERABILITY_SCORES_FLYOUT,
@ -69,16 +68,12 @@ describe('<VulnerabilityFindingFlyout/>', () => {
getAllByText(mockVulnerabilityHit.vulnerability.id as string);
});
it('displays title and reference link for the matching id', () => {
it('displays reference link for the matching id', () => {
(useVulnerabilityFinding as jest.Mock).mockReturnValue({
data: { result: { hits: [{ _source: mockVulnerabilityHit }] } },
});
const { getByTestId } = render(<TestComponent />);
expect(getByTestId(VULNERABILITY_HEADER_TITLE).textContent).toEqual(
mockVulnerabilityHit.vulnerability.title
);
const idLinkElement = getByTestId(VULNERABILITY_HEADER_REFERENCE_LINK);
expect(idLinkElement.textContent).toMatch(
new RegExp(`^${mockVulnerabilityHit.vulnerability.id}`)
@ -97,10 +92,6 @@ describe('<VulnerabilityFindingFlyout/>', () => {
});
const { getByTestId } = render(<TestComponent />);
expect(getByTestId(VULNERABILITY_HEADER_TITLE).textContent).toEqual(
mockQualysVulnerabilityHit.vulnerability.title
);
const idLinkElement = getByTestId(VULNERABILITY_HEADER_REFERENCE_LINK);
expect(idLinkElement.textContent).toMatch(
new RegExp(`^${mockQualysVulnerabilityHit.vulnerability.id[0]}`)
@ -137,10 +128,6 @@ describe('<VulnerabilityFindingFlyout/>', () => {
});
const { getByTestId, queryByTestId } = render(<TestComponent />);
expect(getByTestId(VULNERABILITY_HEADER_TITLE).textContent).toEqual(
mockQualysVulnerabilityHit.vulnerability.title
);
const idLinkElement = queryByTestId(VULNERABILITY_HEADER_REFERENCE_LINK);
expect(idLinkElement).toBeNull();

View file

@ -13,53 +13,26 @@ import {
VULNERABILITY_HEADER_CVE_BADGE,
VULNERABILITY_HEADER_ID,
VULNERABILITY_HEADER_REFERENCE_LINK,
VULNERABILITY_HEADER_TITLE,
} from '../../test_subjects';
import { TestProvider } from '../../../../test/test_provider';
describe('FindingsVulnerabilityFlyoutHeader', () => {
it('renders the component and displays the vulnerability title and CVEs', () => {
it('renders the component and displays the vulnerability CVEs', () => {
render(
<TestProvider>
<FindingsVulnerabilityFlyoutHeader finding={mockQualysVulnerabilityHit} />
</TestProvider>
);
const titleElement = screen.getByTestId(VULNERABILITY_HEADER_TITLE);
const cveslinkElement = screen.getByTestId(VULNERABILITY_HEADER_REFERENCE_LINK);
const cvesBadge = screen.getByTestId(VULNERABILITY_HEADER_CVE_BADGE);
expect(titleElement).toBeInTheDocument();
expect(titleElement).toHaveTextContent(mockQualysVulnerabilityHit.vulnerability.title);
expect(cveslinkElement).toHaveTextContent(mockQualysVulnerabilityHit.vulnerability.id[0]);
expect(cvesBadge).toHaveTextContent(
`${mockQualysVulnerabilityHit.vulnerability.id.length - 1} More`
);
});
it('renders the component and display only CVEs when vulnerability.title is empty', () => {
const vulnerabilityHitWithEmptyTitle = {
...mockQualysVulnerabilityHit,
vulnerability: { ...mockQualysVulnerabilityHit.vulnerability, title: '' },
};
render(
<TestProvider>
<FindingsVulnerabilityFlyoutHeader finding={vulnerabilityHitWithEmptyTitle} />
</TestProvider>
);
const titleElement = screen.getByTestId(VULNERABILITY_HEADER_TITLE);
const cvesElement = screen.getByTestId(VULNERABILITY_HEADER_REFERENCE_LINK);
const cvesBadge = screen.getByTestId(VULNERABILITY_HEADER_CVE_BADGE);
expect(titleElement).toBeInTheDocument();
expect(titleElement).not.toHaveTextContent(mockQualysVulnerabilityHit.vulnerability.title);
expect(cvesElement).toHaveTextContent(vulnerabilityHitWithEmptyTitle.vulnerability.id[0]);
expect(cvesBadge).toHaveTextContent(
`${vulnerabilityHitWithEmptyTitle.vulnerability.id.length - 1} More`
);
});
it('renders the component and display first CVE as text if there is no reference', () => {
const vulnerabilityHitWithNoReference = {
...mockQualysVulnerabilityHit,
@ -80,7 +53,7 @@ describe('FindingsVulnerabilityFlyoutHeader', () => {
);
});
it('renders the component and display onlt CVE without the badge', () => {
it('renders the component and display only CVE without the badge', () => {
const vulnerabilityHitWithSingleCVE = {
...mockQualysVulnerabilityHit,
vulnerability: {

View file

@ -35,7 +35,6 @@ import {
VULNERABILITY_HEADER_CVE_BADGE,
VULNERABILITY_HEADER_REFERENCE_LINK,
VULNERABILITY_HEADER_ID,
VULNERABILITY_HEADER_TITLE,
DATA_SOURCE_VULNERABILITY_FLYOUT,
VULNERABILITY_SCORES_FLYOUT,
} from '../../test_subjects';
@ -144,11 +143,6 @@ export const FindingsVulnerabilityFlyoutHeader = ({
gap: ${euiTheme.size.s};
`}
>
<EuiFlexItem>
<EuiTitle size="m" css={css``}>
<EuiText data-test-subj={VULNERABILITY_HEADER_TITLE}>{vulnerability?.title}</EuiText>
</EuiTitle>
</EuiFlexItem>
{renderCves()}
</EuiFlexGroup>
<EuiSpacer size="m" />

View file

@ -17,8 +17,8 @@ import type {
FindingsVulnerabilityFlyoutContentProps,
FindingsVulnerabilityFlyoutFooterProps,
FindingsVulnerabilityFlyoutHeaderProps,
FindingVulnerabilityFlyoutProps,
FindingsMisconfigurationPanelExpandableFlyoutProps,
FindingsVulnerabilityPanelExpandableFlyoutProps,
} from '@kbn/cloud-security-posture';
import { uiMetricService } from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { CspLoadingState } from './components/csp_loading_state';
@ -169,7 +169,7 @@ export class CspPlugin
},
getCloudSecurityPostureVulnerabilityFlyout: () => {
return {
Component: (props: FindingVulnerabilityFlyoutProps) => (
Component: (props: FindingsVulnerabilityPanelExpandableFlyoutProps['params']) => (
<LazyCspFindingsVulnerabilityFlyout {...props}>
{props.children}
</LazyCspFindingsVulnerabilityFlyout>

View file

@ -16,13 +16,14 @@ import { ExpandableFlyoutApi } from '@kbn/expandable-flyout';
import {
FindingMisconfigurationFlyoutContentProps,
FindingMisconfigurationFlyoutFooterProps,
FindingVulnerabilityFlyoutProps,
FindingsVulnerabilityFlyoutContentProps,
FindingsVulnerabilityFlyoutFooterProps,
FindingsVulnerabilityFlyoutHeaderProps,
FindingsMisconfigurationFlyoutContentProps,
FindingsMisconfigurationFlyoutHeaderProps,
FindingsMisconfigurationPanelExpandableFlyoutProps,
FindingsVulnerabilityPanelExpandableFlyoutProps,
FindingVulnerabilityFullFlyoutContentProps,
} from '@kbn/cloud-security-posture';
import type { CspRouterProps } from './application/csp_router';
import type { CloudSecurityPosturePageId } from './common/navigation/types';
@ -57,7 +58,11 @@ export interface CspClientPluginStart {
Footer: React.FC<FindingMisconfigurationFlyoutFooterProps>;
};
getCloudSecurityPostureVulnerabilityFlyout: () => {
Component: React.FC<FindingVulnerabilityFlyoutProps>;
Component: React.FC<
FindingsVulnerabilityPanelExpandableFlyoutProps['params'] & {
children?: (props: FindingVulnerabilityFullFlyoutContentProps) => ReactNode;
}
>;
Header: React.FC<FindingsVulnerabilityFlyoutHeaderProps>;
Body: React.FC<FindingsVulnerabilityFlyoutContentProps>;
Footer: React.FC<FindingsVulnerabilityFlyoutFooterProps>;

View file

@ -166,7 +166,7 @@ export const InsightsTabCsp = memo(
{activeInsightsId === CspInsightLeftPanelSubTab.MISCONFIGURATIONS ? (
<MisconfigurationFindingsDetailsTable field={field} value={value} scopeId={scopeId} />
) : activeInsightsId === CspInsightLeftPanelSubTab.VULNERABILITIES ? (
<VulnerabilitiesFindingsDetailsTable value={value} />
<VulnerabilitiesFindingsDetailsTable value={value} scopeId={scopeId} />
) : (
<AlertsDetailsTable field={field} value={value} />
)}

View file

@ -7,7 +7,7 @@
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 { EuiSpacer, EuiPanel, EuiText, EuiBasicTable, EuiIcon, EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { buildVulnerabilityEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { DistributionBar } from '@kbn/security-solution-distribution-bar';
@ -19,7 +19,10 @@ import {
useVulnerabilitiesFindings,
VULNERABILITY,
} from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_findings';
import type { MultiValueCellAction } from '@kbn/cloud-security-posture';
import type {
FindingsVulnerabilityPanelExpandableFlyoutPropsPreview,
MultiValueCellAction,
} from '@kbn/cloud-security-posture';
import {
getVulnerabilityStats,
CVSScoreBadge,
@ -40,6 +43,9 @@ import { useGetNavigationUrlParams } from '@kbn/cloud-security-posture/src/hooks
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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { VulnerabilityFindingsPreviewPanelKey } from '../../../flyout/csp_details/vulnerabilities_flyout/constants';
import { EntityIdentifierFields } from '../../../../common/entity_analytics/types';
import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
import type { CloudPostureEntityIdentifier } from '../entity_insight';
@ -51,305 +57,316 @@ type VulnerabilitySortFieldType =
| VULNERABILITY.SEVERITY
| VULNERABILITY.ID
| VULNERABILITY.PACKAGE_NAME
| VULNERABILITY.TITLE;
| VULNERABILITY.TITLE
| 'event';
const EMPTY_VALUE = '-';
export const VulnerabilitiesFindingsDetailsTable = memo(({ value }: { value: string }) => {
const { getSeverityStatusColor } = useGetSeverityStatusColor();
export const VulnerabilitiesFindingsDetailsTable = memo(
({ value, scopeId }: { value: string; scopeId: string }) => {
const { getSeverityStatusColor } = useGetSeverityStatusColor();
useEffect(() => {
uiMetricService.trackUiMetric(
METRIC_TYPE.COUNT,
ENTITY_FLYOUT_EXPAND_VULNERABILITY_VIEW_VISITS
);
}, []);
useEffect(() => {
uiMetricService.trackUiMetric(
METRIC_TYPE.COUNT,
ENTITY_FLYOUT_EXPAND_VULNERABILITY_VIEW_VISITS
);
}, []);
const [currentFilter, setCurrentFilter] = useState<string>('');
const [sortField, setSortField] = useState<VulnerabilitySortFieldType>(VULNERABILITY.SEVERITY);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentFilter, setCurrentFilter] = useState<string>('');
const [sortField, setSortField] = useState<VulnerabilitySortFieldType>(VULNERABILITY.SEVERITY);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const sortFieldDirection: { [key: string]: string } = {};
sortFieldDirection[sortField === 'score' ? 'vulnerability.score.base' : sortField] =
sortDirection;
const { openPreviewPanel } = useExpandableFlyoutApi();
const sorting: EuiTableSortingType<VulnerabilitiesFindingDetailFields> = {
sort: {
field: sortField,
direction: sortDirection,
},
};
const sortFieldDirection: { [key: string]: string } = {};
sortFieldDirection[sortField === 'score' ? 'vulnerability.score.base' : sortField] =
sortDirection;
const { data } = useVulnerabilitiesFindings({
query: buildVulnerabilityEntityFlyoutPreviewQuery('host.name', value, currentFilter),
sort: [sortFieldDirection],
enabled: true,
pageSize: 1,
});
const { counts } = useHasVulnerabilities('host.name', value);
const { critical = 0, high = 0, medium = 0, low = 0, none = 0 } = counts || {};
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const findingsPagination = (findings: VulnerabilitiesFindingDetailFields[]) => {
let pageOfItems;
if (!pageIndex && !pageSize) {
pageOfItems = findings;
} else {
const startIndex = pageIndex * pageSize;
pageOfItems = findings?.slice(startIndex, Math.min(startIndex + pageSize, findings?.length));
}
return {
pageOfItems,
totalItemCount: findings?.length,
};
};
const { pageOfItems, totalItemCount } = findingsPagination(data?.rows || []);
const pagination = {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: [10, 25, 100],
};
const onTableChange = ({ page, sort }: Criteria<VulnerabilitiesFindingDetailFields>) => {
if (page) {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
}
if (sort) {
const { field: fieldSort, direction } = sort;
setSortField(fieldSort);
setSortDirection(direction);
}
};
const getNavUrlParams: ReturnType<typeof useGetNavigationUrlParams> = useGetNavigationUrlParams();
const getVulnerabilityUrl = (name: string, queryField: CloudPostureEntityIdentifier) => {
return getNavUrlParams({ [queryField]: name }, 'vulnerabilities');
};
const getVulnerabilityUrlFilteredByVulnerabilityAndResourceId = (
vulnerabilityId: string | string[],
resourceId: string,
packageName: string,
packageVersion: string
) => {
return getNavUrlParams(
{
'vulnerability.id': vulnerabilityId,
'resource.id': resourceId,
'package.name': encodeURIComponent(packageName),
'package.version': encodeURIComponent(packageVersion),
const sorting: EuiTableSortingType<VulnerabilitiesFindingDetailFields> = {
sort: {
field: sortField,
direction: sortDirection,
},
'vulnerabilities'
};
const { data } = useVulnerabilitiesFindings({
query: buildVulnerabilityEntityFlyoutPreviewQuery('host.name', value, currentFilter),
sort: [sortFieldDirection],
enabled: true,
pageSize: 1,
});
const { counts } = useHasVulnerabilities('host.name', value);
const { critical = 0, high = 0, medium = 0, low = 0, none = 0 } = counts || {};
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const findingsPagination = (findings: VulnerabilitiesFindingDetailFields[]) => {
let pageOfItems;
if (!pageIndex && !pageSize) {
pageOfItems = findings;
} else {
const startIndex = pageIndex * pageSize;
pageOfItems = findings?.slice(
startIndex,
Math.min(startIndex + pageSize, findings?.length)
);
}
return {
pageOfItems,
totalItemCount: findings?.length,
};
};
const { pageOfItems, totalItemCount } = findingsPagination(data?.rows || []);
const pagination = {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: [10, 25, 100],
};
const onTableChange = ({ page, sort }: Criteria<VulnerabilitiesFindingDetailFields>) => {
if (page) {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
}
if (sort) {
const { field: fieldSort, direction } = sort;
setSortField(fieldSort);
setSortDirection(direction);
}
};
const getNavUrlParams: ReturnType<typeof useGetNavigationUrlParams> =
useGetNavigationUrlParams();
const getVulnerabilityUrl = (name: string, queryField: CloudPostureEntityIdentifier) => {
return getNavUrlParams({ [queryField]: name }, 'vulnerabilities');
};
const vulnerabilityStats = getVulnerabilityStats(
{
critical,
high,
medium,
low,
none,
},
getSeverityStatusColor,
setCurrentFilter,
currentFilter
);
};
const vulnerabilityStats = getVulnerabilityStats(
{
critical,
high,
medium,
low,
none,
},
getSeverityStatusColor,
setCurrentFilter,
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 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 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',
}
),
},
]
: []),
];
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} />;
},
[]
);
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>;
}
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',
name: '',
width: '5%',
render: (
vulnerability: VulnerabilitiesPackage,
finding: VulnerabilitiesFindingDetailFields
) => (
<EuiButtonIcon
iconType="expand"
onClick={() => {
const previewPanelProps: FindingsVulnerabilityPanelExpandableFlyoutPropsPreview = {
id: VulnerabilityFindingsPreviewPanelKey,
params: {
vulnerabilityId: vulnerability?.id,
resourceId: finding?.resource?.id || '',
packageName: vulnerability?.package?.name,
packageVersion: vulnerability?.package?.version,
eventId: finding?.event?.id || '',
scopeId,
isPreviewMode: true,
banner: {
title: i18n.translate(
'xpack.securitySolution.flyout.right.vulnerabilityFinding.PreviewTitle',
{
defaultMessage: 'Preview vulnerability details',
}
),
backgroundColor: 'warning',
textColor: 'warning',
},
},
};
openPreviewPanel(previewPanelProps);
}}
/>
),
},
{
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) => {
if (Array.isArray(title)) {
return <EuiText size="s">{title.join(', ')}</EuiText>;
}
return <EuiText size="s">{title || EMPTY_VALUE}</EuiText>;
},
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.vulnerabilityTitleColumnName',
{ defaultMessage: 'Vulnerability Title' }
),
width: '25%',
sortable: true,
},
{
field: VULNERABILITY.ID,
render: (id: string, finding: VulnerabilitiesFindingDetailFields) =>
renderMultiValueCell(VULNERABILITY.ID, finding),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.vulnerabilityIdColumnName',
{ defaultMessage: 'CVE ID' }
),
width: '20%',
sortable: true,
},
{
field: VULNERABILITY.SEVERITY,
render: (severity: string) => (
<>
<EuiText size="s">
<SeverityStatusBadge severity={getNormalizedSeverity(severity)} />
</EuiText>
</>
),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName',
{ defaultMessage: 'Severity' }
),
width: '10%',
sortable: true,
},
{
field: VULNERABILITY.PACKAGE_NAME,
render: (packageName: string, finding: VulnerabilitiesFindingDetailFields) =>
renderMultiValueCell(VULNERABILITY.PACKAGE_NAME, finding),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName',
{ defaultMessage: 'Package' }
),
width: '30%',
sortable: true,
},
];
return (
<MultiValueCellPopover<VulnerabilitiesFindingDetailFields>
items={cellValue}
field={field}
object={finding}
renderItem={renderItem}
firstItemRenderer={(item) => <EuiText size="s">{item}</EuiText>}
/>
<>
<EuiPanel hasShadow={false}>
<SecuritySolutionLinkAnchor
deepLinkId={SecurityPageName.cloudSecurityPostureFindings}
path={`${getVulnerabilityUrl(value, EntityIdentifierFields.hostName)}`}
target={'_blank'}
external={false}
onClick={() => {
uiMetricService.trackUiMetric(
METRIC_TYPE.CLICK,
NAV_TO_FINDINGS_BY_HOST_NAME_FROM_ENTITY_FLYOUT
);
}}
>
{i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.tableTitle',
{
defaultMessage: 'Vulnerability ',
}
)}
<EuiIcon type={'popout'} />
</SecuritySolutionLinkAnchor>
<EuiSpacer size="xl" />
<DistributionBar stats={vulnerabilityStats} />
<EuiSpacer size="l" />
<EuiBasicTable
items={pageOfItems || []}
rowHeader="result"
columns={columns}
pagination={pagination}
onChange={onTableChange}
data-test-subj={'securitySolutionFlyoutVulnerabilitiesFindingsTable'}
sorting={sorting}
/>
</EuiPanel>
</>
);
};
const columns: Array<EuiBasicTableColumn<VulnerabilitiesFindingDetailFields>> = [
{
field: 'vulnerability',
name: '',
width: '5%',
render: (
vulnerability: VulnerabilitiesPackage,
finding: VulnerabilitiesFindingDetailFields
) => (
<SecuritySolutionLinkAnchor
deepLinkId={SecurityPageName.cloudSecurityPostureFindings}
path={`${getVulnerabilityUrlFilteredByVulnerabilityAndResourceId(
vulnerability?.id,
finding?.resource?.id || '',
vulnerability?.package?.name,
vulnerability?.package?.version
)}`}
target={'_blank'}
external={false}
>
<EuiIcon type={'popout'} />
</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) => {
if (Array.isArray(title)) {
return <EuiText size="s">{title.join(', ')}</EuiText>;
}
return <EuiText size="s">{title || EMPTY_VALUE}</EuiText>;
},
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.vulnerabilityTitleColumnName',
{ defaultMessage: 'Vulnerability Title' }
),
width: '25%',
sortable: true,
},
{
field: VULNERABILITY.ID,
render: (id: string, finding: VulnerabilitiesFindingDetailFields) =>
renderMultiValueCell(VULNERABILITY.ID, finding),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.vulnerabilityIdColumnName',
{ defaultMessage: 'CVE ID' }
),
width: '20%',
sortable: true,
},
{
field: VULNERABILITY.SEVERITY,
render: (severity: string) => (
<>
<EuiText size="s">
<SeverityStatusBadge severity={getNormalizedSeverity(severity)} />
</EuiText>
</>
),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName',
{ defaultMessage: 'Severity' }
),
width: '10%',
sortable: true,
},
{
field: VULNERABILITY.PACKAGE_NAME,
render: (packageName: string, finding: VulnerabilitiesFindingDetailFields) =>
renderMultiValueCell(VULNERABILITY.PACKAGE_NAME, finding),
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName',
{ defaultMessage: 'Package' }
),
width: '30%',
sortable: true,
},
];
return (
<>
<EuiPanel hasShadow={false}>
<SecuritySolutionLinkAnchor
deepLinkId={SecurityPageName.cloudSecurityPostureFindings}
path={`${getVulnerabilityUrl(value, EntityIdentifierFields.hostName)}`}
target={'_blank'}
external={false}
onClick={() => {
uiMetricService.trackUiMetric(
METRIC_TYPE.CLICK,
NAV_TO_FINDINGS_BY_HOST_NAME_FROM_ENTITY_FLYOUT
);
}}
>
{i18n.translate('xpack.securitySolution.flyout.left.insights.vulnerability.tableTitle', {
defaultMessage: 'Vulnerability ',
})}
<EuiIcon type={'popout'} />
</SecuritySolutionLinkAnchor>
<EuiSpacer size="xl" />
<DistributionBar stats={vulnerabilityStats} />
<EuiSpacer size="l" />
<EuiBasicTable
items={pageOfItems || []}
rowHeader="result"
columns={columns}
pagination={pagination}
onChange={onTableChange}
data-test-subj={'securitySolutionFlyoutVulnerabilitiesFindingsTable'}
sorting={sorting}
/>
</EuiPanel>
</>
);
});
}
);
VulnerabilitiesFindingsDetailsTable.displayName = 'VulnerabilitiesFindingsDetailsTable';

View file

@ -10,20 +10,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer, EuiFlyoutFooter } from '
import type { FindingsMisconfigurationPanelExpandableFlyoutProps } from '@kbn/cloud-security-posture';
import { CspEvaluationBadge } from '@kbn/cloud-security-posture';
import { i18n } from '@kbn/i18n';
import { useGetNavigationUrlParams } from '@kbn/cloud-security-posture/src/hooks/use_get_navigation_url_params';
import { METRIC_TYPE } from '@kbn/analytics';
import {
uiMetricService,
NAV_TO_FINDINGS_BY_RULE_NAME_FROM_ENTITY_FLYOUT,
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { SecurityPageName } from '@kbn/deeplinks-security';
import { FlyoutNavigation } from '../../../shared/components/flyout_navigation';
import { FlyoutHeader } from '../../../shared/components/flyout_header';
import { useKibana } from '../../../../common/lib/kibana';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
import { FlyoutTitle } from '../../../shared/components/flyout_title';
import { FlyoutBody } from '../../../shared/components/flyout_body';
import { SecuritySolutionLinkAnchor } from '../../../../common/components/links';
export const FindingsMisconfigurationPanel = ({
resourceId,
@ -33,20 +25,9 @@ export const FindingsMisconfigurationPanel = ({
const { cloudSecurityPosture } = useKibana().services;
const CspFlyout = cloudSecurityPosture.getCloudSecurityPostureMisconfigurationFlyout();
const getNavUrlParams = useGetNavigationUrlParams();
const getFindingsPageUrlFilteredByRuleAndResourceId = (
findingRuleId: string,
findingResourceId: string
) => {
return getNavUrlParams(
{ 'rule.id': findingRuleId, 'resource.id': findingResourceId },
'configurations'
);
};
return (
<>
<FlyoutNavigation flyoutIsExpandable={false} />
<FlyoutNavigation flyoutIsExpandable={false} isPreviewMode={isPreviewMode} />
<CspFlyout.Component ruleId={ruleId} resourceId={resourceId}>
{({ finding, createRuleFn }) => {
return (
@ -70,27 +51,7 @@ export const FindingsMisconfigurationPanel = ({
</EuiFlexItem>
)}
<EuiFlexItem>
{isPreviewMode ? (
<SecuritySolutionLinkAnchor
deepLinkId={SecurityPageName.cloudSecurityPostureFindings}
path={`${getFindingsPageUrlFilteredByRuleAndResourceId(
finding.rule.id,
finding.resource.id
)}`}
target={'_blank'}
external={false}
onClick={() => {
uiMetricService.trackUiMetric(
METRIC_TYPE.CLICK,
NAV_TO_FINDINGS_BY_RULE_NAME_FROM_ENTITY_FLYOUT
);
}}
>
<FlyoutTitle title={finding.rule.name} isLink />
</SecuritySolutionLinkAnchor>
) : (
<FlyoutTitle title={finding.rule.name} />
)}
<FlyoutTitle title={finding.rule.name} />
</EuiFlexItem>
</EuiFlexGroup>
<CspFlyout.Header finding={finding} />

View file

@ -5,7 +5,13 @@
* 2.0.
*/
import type { FindingVulnerabilityPanelExpandableFlyoutProps } from '@kbn/cloud-security-posture';
import type {
FindingsVulnerabilityPanelExpandableFlyoutPropsNonPreview,
FindingsVulnerabilityPanelExpandableFlyoutPropsPreview,
} from '@kbn/cloud-security-posture';
export const VulnerabilityFindingsPanelKey: FindingVulnerabilityPanelExpandableFlyoutProps['key'] =
export const VulnerabilityFindingsPanelKey: FindingsVulnerabilityPanelExpandableFlyoutPropsNonPreview['id'] =
'findings-vulnerability-panel';
export const VulnerabilityFindingsPreviewPanelKey: FindingsVulnerabilityPanelExpandableFlyoutPropsPreview['id'] =
'findings-vulnerability-panel-preview';

View file

@ -0,0 +1,104 @@
/*
* 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 { FindingsVulnerabilityPanel } from '.';
import { useDateFormat, useKibana, useTimeZone } from '../../../../common/lib/kibana';
import { TestProviders } from '../../../../common/mock';
import {
useExpandableFlyoutApi,
useExpandableFlyoutHistory,
useExpandableFlyoutState,
} from '@kbn/expandable-flyout';
import type { FindingsVulnerabilityPanelExpandableFlyoutProps } from '@kbn/cloud-security-posture';
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: jest.fn(),
useDateFormat: jest.fn(),
useTimeZone: jest.fn(),
}));
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
useExpandableFlyoutHistory: jest.fn(),
useExpandableFlyoutState: jest.fn(),
}));
jest.mock('@kbn/cloud-security-posture/src/hooks/use_get_navigation_url_params', () => ({
useGetNavigationUrlParams: () => () => 'mocked-nav-url',
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderComponent = (Component: any) => {
(useKibana as jest.Mock).mockReturnValue({
services: {
cloudSecurityPosture: {
getCloudSecurityPostureVulnerabilityFlyout: () => ({
Component,
Header: () => <div />,
Body: () => <div />,
Footer: () => <div />,
}),
},
},
});
render(
<TestProviders>
<FindingsVulnerabilityPanel {...baseProps} />
</TestProviders>
);
};
const createMockFlyoutComponent =
(title?: string) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ children }: any) =>
children({
finding: {
vulnerability: {
title,
severity: 'High',
published_date: '2025-05-22T00:00:00.000Z',
},
'@timestamp': '2025-05-22T00:00:00.000Z',
},
createRuleFn: jest.fn(),
});
const baseProps: FindingsVulnerabilityPanelExpandableFlyoutProps['params'] = {
vulnerabilityId: 'vulnerability_id',
resourceId: 'resource_id',
packageName: 'package_name',
packageVersion: 'package_version',
eventId: 'event_id',
isPreviewMode: false,
};
const flyoutHistoryMock = [{ lastOpen: Date.now(), panel: { id: 'id_mock', params: {} } }];
beforeEach(() => {
(useExpandableFlyoutApi as jest.Mock).mockReturnValue({ closeLeftPanel: jest.fn() });
(useExpandableFlyoutHistory as jest.Mock).mockReturnValue(flyoutHistoryMock);
(useExpandableFlyoutState as jest.Mock).mockReturnValue({});
(useDateFormat as jest.Mock).mockReturnValue('MMM D, YYYY @ HH:mm:ss.SSS');
(useTimeZone as jest.Mock).mockReturnValue('UTC');
});
describe('FindingsVulnerabilityPanel', () => {
it('renders the vulnerability title when available', () => {
renderComponent(createMockFlyoutComponent('Test Vulnerability Title'));
expect(screen.getByText('Test Vulnerability Title')).toBeInTheDocument();
});
it('renders fallback title when vulnerability title is missing', () => {
renderComponent(createMockFlyoutComponent(undefined));
expect(screen.getByText('No title available')).toBeInTheDocument();
});
});

View file

@ -6,10 +6,10 @@
*/
import React from 'react';
import type { FindingsVulnerabilityPanelExpandableFlyoutProps } from '@kbn/cloud-security-posture';
import {
SeverityStatusBadge,
getNormalizedSeverity,
type FindingVulnerabilityFlyoutProps,
type FindingVulnerabilityFullFlyoutContentProps,
} from '@kbn/cloud-security-posture';
import { EuiFlexGroup, EuiFlexItem, EuiFlyoutFooter, EuiSpacer, EuiText } from '@elastic/eui';
@ -20,6 +20,7 @@ import { FlyoutNavigation } from '../../../shared/components/flyout_navigation';
import { useKibana } from '../../../../common/lib/kibana';
import { FlyoutHeader } from '../../../shared/components/flyout_header';
import { FlyoutBody } from '../../../shared/components/flyout_body';
import { FlyoutTitle } from '../../../shared/components/flyout_title';
export const FindingsVulnerabilityPanel = ({
vulnerabilityId,
@ -27,13 +28,14 @@ export const FindingsVulnerabilityPanel = ({
packageName,
packageVersion,
eventId,
}: FindingVulnerabilityFlyoutProps) => {
isPreviewMode,
}: FindingsVulnerabilityPanelExpandableFlyoutProps['params']) => {
const { cloudSecurityPosture } = useKibana().services;
const CspVulnerabilityFlyout = cloudSecurityPosture.getCloudSecurityPostureVulnerabilityFlyout();
return (
<>
<FlyoutNavigation flyoutIsExpandable={false} />
<FlyoutNavigation flyoutIsExpandable={false} isPreviewMode={isPreviewMode} />
<CspVulnerabilityFlyout.Component
vulnerabilityId={vulnerabilityId}
resourceId={resourceId}
@ -42,6 +44,12 @@ export const FindingsVulnerabilityPanel = ({
eventId={eventId}
>
{({ finding, createRuleFn }: FindingVulnerabilityFullFlyoutContentProps) => {
const vulnerabilityTitle =
finding?.vulnerability?.title ??
i18n.translate('xpack.securitySolution.csp.vulnerabilitiesFlyout.emptyTitleHolder', {
defaultMessage: 'No title available',
});
return (
<>
<FlyoutHeader>
@ -79,14 +87,18 @@ export const FindingsVulnerabilityPanel = ({
</EuiFlexItem>
)}
</EuiFlexGroup>
<FlyoutTitle title={vulnerabilityTitle} />
<EuiSpacer size="xs" />
<CspVulnerabilityFlyout.Header finding={finding} />
</FlyoutHeader>
<FlyoutBody>
<CspVulnerabilityFlyout.Body finding={finding} />
</FlyoutBody>
<EuiFlyoutFooter>
<CspVulnerabilityFlyout.Footer createRuleFn={createRuleFn} />
</EuiFlyoutFooter>
{!isPreviewMode && (
<EuiFlyoutFooter>
<CspVulnerabilityFlyout.Footer createRuleFn={createRuleFn} />
</EuiFlyoutFooter>
)}
</>
);
}}

View file

@ -9,9 +9,10 @@ import React, { memo, useCallback } from 'react';
import { ExpandableFlyout, type ExpandableFlyoutProps } from '@kbn/expandable-flyout';
import { useEuiTheme } from '@elastic/eui';
import type {
FindingVulnerabilityPanelExpandableFlyoutProps,
FindingsMisconfigurationPanelExpandableFlyoutPropsNonPreview,
FindingsMisconfigurationPanelExpandableFlyoutPropsPreview,
FindingsVulnerabilityPanelExpandableFlyoutPropsNonPreview,
FindingsVulnerabilityPanelExpandableFlyoutPropsPreview,
} from '@kbn/cloud-security-posture';
import type { GenericEntityDetailsExpandableFlyoutProps } from './entity_details/generic_details_left';
import {
@ -77,7 +78,10 @@ import {
} from './csp_details/findings_flyout/constants';
import { FindingsMisconfigurationPanel } from './csp_details/findings_flyout/findings_right';
import { IOCPanelKey } from './ai_for_soc/constants/panel_keys';
import { VulnerabilityFindingsPanelKey } from './csp_details/vulnerabilities_flyout/constants';
import {
VulnerabilityFindingsPanelKey,
VulnerabilityFindingsPreviewPanelKey,
} from './csp_details/vulnerabilities_flyout/constants';
import { FindingsVulnerabilityPanel } from './csp_details/vulnerabilities_flyout/vulnerabilities_right';
/**
@ -242,7 +246,15 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
key: VulnerabilityFindingsPanelKey,
component: (props) => (
<FindingsVulnerabilityPanel
{...(props as FindingVulnerabilityPanelExpandableFlyoutProps).params}
{...(props as FindingsVulnerabilityPanelExpandableFlyoutPropsNonPreview).params}
/>
),
},
{
key: VulnerabilityFindingsPreviewPanelKey,
component: (props) => (
<FindingsVulnerabilityPanel
{...(props as FindingsVulnerabilityPanelExpandableFlyoutPropsPreview).params}
/>
),
},