[Cloud Posture] Vul mgmt flyout details panel (#154873)

## Summary

Summarize your PR. If it involves visual changes include a screenshot or
gif.

<img width="1695" alt="Screen Shot 2023-04-12 at 5 04 30 PM"
src="https://user-images.githubusercontent.com/17135495/231596052-d47d0e7a-7abb-4aa1-a022-0fd1b1def981.png">
<img width="1656" alt="Screen Shot 2023-04-12 at 5 30 55 PM"
src="https://user-images.githubusercontent.com/17135495/231596060-89ea7d40-8727-4dab-a3ba-c57cf83ad0cc.png">
<img width="1659" alt="Screen Shot 2023-04-12 at 5 31 09 PM"
src="https://user-images.githubusercontent.com/17135495/231596067-839c0c1f-84a6-42b5-9683-77c70594af65.png">

Flyou details Feature includes:

Results are fetched from the
logs-cloud_security_posture.vulnerabilities-default index.
A Flyout opens when clicking the Expand Icon in the Vulnerabilities
table.
The Flyout has two tabs: Overview and JSON.
The Overview tab consists of four sections: CVSS, Data source, Publish
date, Description, Fixes, and Vulnerability Scores

TODO
Add pagination in follow up pr
Add unit tests
This commit is contained in:
Lola 2023-04-17 10:17:47 -04:00 committed by GitHub
parent d213107c15
commit eb6bfd8476
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 616 additions and 126 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiBadge, EuiIcon, EuiTextColor, useEuiFontSize } from '@elastic/eui';
import { EuiBadge, EuiIcon, EuiTextColor } from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import { float } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -21,10 +21,6 @@ interface SeverityStatusBadgeProps {
score: float;
}
interface ExploitsStatusBadgeProps {
totalExploits: number;
}
export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => {
const color = getCvsScoreColor(score);
const versionDisplay = version ? `v${version.split('.')[0]}` : null;
@ -60,28 +56,15 @@ export const SeverityStatusBadge = ({ score, status }: SeverityStatusBadgeProps)
const color = getCvsScoreColor(score);
return (
<>
<EuiIcon type="dot" color={color} />
{status}
</>
);
};
export const ExploitsStatusBadge = ({ totalExploits }: ExploitsStatusBadgeProps) => {
const xxsFontSize = useEuiFontSize('xxs').fontSize;
return (
<EuiBadge
color={'hollow'}
iconType="bug"
<div
css={css`
.euiBadge__text {
font-weight: 400;
font-size: ${xxsFontSize};
}
display: flex;
flex-direction: row;
align-items: center;
`}
>
{totalExploits}
</EuiBadge>
<EuiIcon type="dot" color={color} />
{status}
</div>
);
};

View file

@ -20,48 +20,7 @@ export interface VulnerabilityRecord {
sequence: number;
outcome: string;
};
vulnerability: {
score: {
version: string;
impact: number;
base: number;
};
cwe: string[];
id: string;
title: string;
reference: string;
severity: string;
cvss: {
nvd: {
V3Vector: string;
V3Score: number;
};
redhat?: {
V3Vector: string;
V3Score: number;
};
ghsa?: {
V3Vector: string;
V3Score: number;
};
};
data_source: {
ID: string;
Name: string;
URL: string;
};
enumeration: string;
description: string;
classification: string;
scanner: {
vendor: string;
};
package: {
version: string;
name: string;
fixed_version: string;
};
};
vulnerability: Vulnerability;
ecs: {
version: string;
};
@ -116,3 +75,58 @@ export interface VulnerabilityRecord {
commit_time: string;
};
}
export interface Vulnerability {
published_at: string;
score: {
version: string;
impact: number;
base: number;
};
cwe: string[];
id: string;
title: string;
reference: string;
severity: string;
cvss: {
nvd: VectorScoreBase;
redhat?: VectorScoreBase;
ghsa?: VectorScoreBase;
};
data_source: {
ID: string;
Name: string;
URL: string;
};
enumeration: string;
description: string;
classification: string;
scanner: {
vendor: string;
};
package: {
version: string;
name: string;
fixed_version: string;
};
}
export interface VectorScoreBase {
V3Score?: number;
V3Vector?: string;
V2Score?: number;
V2Vector?: string;
}
export type Vendor = 'NVD' | 'Red Hat' | 'GHSA';
export interface CVSScoreProps {
vectorBaseScore: VectorScoreBase;
vendor: string;
}
export interface Vector {
version: string;
vector: string;
score: number | undefined;
}

View file

@ -7,6 +7,7 @@
import { EuiDataGridColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { VectorScoreBase, Vector } from './types';
export const vulnerabilitiesColumns = {
actions: 'actions',
@ -85,3 +86,29 @@ export const getVulnerabilitiesColumnsGrid = (): EuiDataGridColumn[] => {
},
];
};
export const getVectorScoreList = (vectorBaseScore: VectorScoreBase) => {
const result: Vector[] = [];
const v2Vector = vectorBaseScore?.V2Vector;
const v2Score = vectorBaseScore?.V2Score;
const v3Vector = vectorBaseScore?.V3Vector;
const v3Score = vectorBaseScore?.V3Score;
if (v2Vector) {
result.push({
version: '2.0',
vector: v2Vector,
score: v2Score,
});
}
if (v3Vector) {
result.push({
version: '2.0',
vector: v3Vector,
score: v3Score,
});
}
return result;
};

View file

@ -17,7 +17,7 @@ import {
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { DataView } from '@kbn/data-views-plugin/common';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../common/constants';
import { useCloudPostureTable } from '../../common/hooks/use_cloud_posture_table';
@ -29,6 +29,7 @@ import { ErrorCallout } from '../configurations/layout/error_callout';
import { FindingsSearchBar } from '../configurations/layout/findings_search_bar';
import { useFilteredDataView } from '../../common/api/use_filtered_data_view';
import { CVSScoreBadge, SeverityStatusBadge } from '../../components/vulnerability_badges';
import { VulnerabilityFindingFlyout } from './vulnerabilities_finding_flyout/vulnerability_finding_flyout';
import { NoVulnerabilitiesStates } from '../../components/no_vulnerabilities_states';
import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api';
@ -79,6 +80,21 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
enabled: !queryError,
});
const [isVulnerabilityDetailFlyoutVisible, setIsVulnerabilityDetailFlyoutVisible] =
useState(false);
const [vulnerability, setVulnerability] = useState<VulnerabilityRecord>();
const showFlyout = (vulnerabilityRecord: VulnerabilityRecord) => {
setIsVulnerabilityDetailFlyoutVisible(true);
setVulnerability(vulnerabilityRecord);
};
const hideFlyout = () => {
setIsVulnerabilityDetailFlyoutVisible(false);
setVulnerability(undefined);
};
const renderCellValue = useMemo(() => {
return ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => {
const vulnerabilityRow = data?.page[rowIndex] as VulnerabilityRecord;
@ -92,7 +108,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
iconType="expand"
aria-label="View"
onClick={() => {
alert(`Flyout id ${vulnerabilityRow.vulnerability.id}`);
showFlyout(vulnerabilityRow);
}}
/>
);
@ -181,64 +197,73 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
}
/>
) : (
<EuiDataGrid
css={css`
& .euiDataGridHeaderCell__icon {
display: none;
}
& .euiDataGrid__controls {
border-bottom: none;
}
& .euiButtonIcon {
color: ${euiTheme.colors.primary};
}
& .euiDataGridRowCell {
font-size: ${euiTheme.size.m};
}
`}
aria-label="Data grid styling demo"
columns={columns}
columnVisibility={{
visibleColumns: columns.map(({ id }) => id),
setVisibleColumns: () => {},
}}
rowCount={data?.total}
toolbarVisibility={{
showColumnSelector: false,
showDisplaySelector: false,
showKeyboardShortcuts: false,
additionalControls: {
left: {
prepend: (
<EuiButtonEmpty size="xs" color="text">
{i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', {
defaultMessage:
'{total, plural, one {# Vulnerability} other {# Vulnerabilities}}',
values: { total: data?.total },
})}
</EuiButtonEmpty>
),
<>
<EuiDataGrid
css={css`
& .euiDataGridHeaderCell__icon {
display: none;
}
& .euiDataGrid__controls {
border-bottom: none;
}
& .euiButtonIcon {
color: ${euiTheme.colors.primary};
}
& .euiDataGridRowCell {
font-size: ${euiTheme.size.m};
}
`}
aria-label="Data grid styling demo"
columns={columns}
columnVisibility={{
visibleColumns: columns.map(({ id }) => id),
setVisibleColumns: () => {},
}}
rowCount={data?.total}
toolbarVisibility={{
showColumnSelector: false,
showDisplaySelector: false,
showKeyboardShortcuts: false,
additionalControls: {
left: {
prepend: (
<EuiButtonEmpty size="xs" color="text">
{i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', {
defaultMessage:
'{total, plural, one {# Vulnerability} other {# Vulnerabilities}}',
values: { total: data?.total },
})}
</EuiButtonEmpty>
),
},
},
},
}}
gridStyle={{
border: 'horizontal',
cellPadding: 'l',
stripes: false,
rowHover: 'none',
header: 'underline',
}}
renderCellValue={renderCellValue}
inMemory={{ level: 'sorting' }}
sorting={{ columns: sort, onSort }}
pagination={{
pageIndex,
pageSize,
pageSizeOptions: [10, 25, 100],
onChangeItemsPerPage,
onChangePage,
}}
/>
}}
gridStyle={{
border: 'horizontal',
cellPadding: 'l',
stripes: false,
rowHover: 'none',
header: 'underline',
}}
renderCellValue={renderCellValue}
inMemory={{ level: 'sorting' }}
sorting={{ columns: sort, onSort }}
pagination={{
pageIndex,
pageSize,
pageSizeOptions: [10, 25, 100],
onChangeItemsPerPage,
onChangePage,
}}
/>
{/* Todo: Add Pagination */}
{isVulnerabilityDetailFlyoutVisible && !!vulnerability && (
<VulnerabilityFindingFlyout
vulnerabilityRecord={vulnerability}
closeFlyout={hideFlyout}
/>
)}
</>
)}
</>
);

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiLink,
EuiTab,
EuiTabs,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { VulnerabilityOverviewTab } from './vulnerability_overview_tab';
import { VulnerabilityJsonTab } from './vulnerability_json_tab';
import { SeverityStatusBadge } from '../../../components/vulnerability_badges';
import { VulnerabilityRecord } from '../types';
const overviewTabId = 'overview';
const jsonTabId = 'json';
export const VulnerabilityFindingFlyout = ({
closeFlyout,
vulnerabilityRecord,
}: {
closeFlyout: () => void;
vulnerabilityRecord: VulnerabilityRecord;
}) => {
const [selectedTabId, setSelectedTabId] = useState(overviewTabId);
const vulnerability = vulnerabilityRecord?.vulnerability;
const resourceName = vulnerabilityRecord?.resource?.name;
const tabs = useMemo(
() => [
{
id: overviewTabId,
name: (
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityFindingFlyout.overviewTabLabel"
defaultMessage="Overview"
/>
),
content: <VulnerabilityOverviewTab vulnerability={vulnerability} />,
},
{
id: jsonTabId,
name: (
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityFindingFlyout.jsonTabLabel"
defaultMessage="JSON"
/>
),
content: <VulnerabilityJsonTab vulnerabilityRecord={vulnerabilityRecord} />,
},
],
[vulnerability, vulnerabilityRecord]
);
const onSelectedTabChanged = (id: string) => setSelectedTabId(id);
const renderTabs = () =>
tabs.map((tab, index) => (
<EuiTab
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
key={index}
>
{tab.name}
</EuiTab>
));
const selectedTabContent = useMemo(
() => tabs.find((obj) => obj.id === selectedTabId)?.content,
[selectedTabId, tabs]
);
const nvdDomain = 'https://nvd';
const nvdWebsite = `${nvdDomain}.nist.gov/vuln/detail/${vulnerabilityRecord?.vulnerability?.id}`;
const vulnerabilityReference = vulnerability?.reference.startsWith(nvdDomain)
? vulnerability?.reference
: nvdWebsite;
return (
<EuiFlyout onClose={closeFlyout}>
<EuiFlyoutHeader>
<EuiFlexGroup
direction="column"
css={css`
gap: ${euiThemeVars.euiSizeS};
`}
>
<EuiFlexItem>
<SeverityStatusBadge
score={vulnerability?.score?.impact}
status={vulnerability?.severity}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup
direction="column"
css={css`
gap: ${euiThemeVars.euiSizeS};
`}
>
<EuiFlexItem>
<EuiTitle
size="m"
css={css`
color: ${euiThemeVars.euiColorPrimaryText};
line-height: 32px;
`}
>
<EuiLink target={'_blank'} href={vulnerabilityReference}>
{vulnerability?.id}
</EuiLink>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<p
css={css`
line-height: 20px;
margin-bottom: ${euiThemeVars.euiSizeM};
`}
>
{`${resourceName} | ${vulnerability?.package?.name} ${vulnerability?.package?.version}`}
</p>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiTabs>{renderTabs()}</EuiTabs>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>{selectedTabContent}</EuiFlyoutBody>
{/* Todo: Add Pagination */}
</EuiFlyout>
);
};

View file

@ -0,0 +1,32 @@
/*
* 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 { CodeEditor } from '@kbn/kibana-react-plugin/public';
import React from 'react';
import { XJsonLang } from '@kbn/monaco';
import { VulnerabilityRecord } from '../types';
interface VulnerabilityJsonTabProps {
vulnerabilityRecord: VulnerabilityRecord;
}
export const VulnerabilityJsonTab = ({ vulnerabilityRecord }: VulnerabilityJsonTabProps) => {
const offsetHeight = 188;
return (
<div style={{ position: 'absolute', inset: 0, top: offsetHeight }}>
<CodeEditor
isCopyable
allowFullScreen
languageId={XJsonLang.ID}
value={JSON.stringify(vulnerabilityRecord, null, 2)}
options={{
readOnly: true,
lineNumbers: 'on',
folding: true,
}}
/>
</div>
);
};

View file

@ -0,0 +1,260 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiLink, EuiText } from '@elastic/eui';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import moment from 'moment';
import React from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { CVSScoreBadge } from '../../../components/vulnerability_badges';
import { CVSScoreProps, VectorScoreBase, Vendor, Vulnerability } from '../types';
import { getVectorScoreList } from '../utils';
const cvssVendors: Record<string, Vendor> = {
nvd: 'NVD',
redhat: 'Red Hat',
ghsa: 'GHSA',
};
interface VulnerabilityTabProps {
vulnerability: Vulnerability;
}
const CVSScore = ({ vectorBaseScore, vendor }: CVSScoreProps) => {
const vendorName =
cvssVendors[vendor] ??
i18n.translate(
'xpack.csp.vulnerabilities.vulnerabilityOverviewTab.cvsScore.unknownVendorName',
{
defaultMessage: 'Unknown vendor',
}
);
const vectorScores = getVectorScoreList(vectorBaseScore);
return (
<EuiFlexGroup
alignItems="center"
css={css`
border: 1px solid #d3dae6;
border-radius: 6px;
padding: ${euiThemeVars.euiSizeM};
`}
>
<EuiFlexItem
grow={false}
css={css`
width: 94px;
font-weight: 600;
`}
>
{vendorName}
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s">
{vectorScores.length > 0 &&
vectorScores.map((vectorScore) => <VectorScore vectorScore={vectorScore} />)}
</EuiFlexGroup>
</EuiFlexGroup>
);
};
const VectorScore = ({
vectorScore,
}: {
vectorScore: {
vector: string;
score: number | undefined;
version: string;
};
}) => {
const { score, vector, version } = vectorScore;
return (
<>
<EuiFlexGroup
alignItems="center"
css={css`
background: #f1f4fa;
padding: ${euiThemeVars.euiSizeXS} ${euiThemeVars.euiSizeS};
border-radius: 6px;
`}
>
<EuiFlexItem>
<EuiText
css={css`
font-size: ${euiThemeVars.euiFontSizeM};
`}
>
{vector}{' '}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{score && <CVSScoreBadge score={score} version={version} />}
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
const VulnerabilityOverviewTiles = ({ vulnerability }: VulnerabilityTabProps) => {
const tileStyle = css`
padding: ${euiThemeVars.euiFontSizeM};
background: ${euiThemeVars.euiColorLightestShade};
border-radius: 6px;
height: 74px;
`;
const tileTitleTextStyle = css`
line-height: 20px;
margin-bottom: 6px;
`;
const date = moment(vulnerability?.published_at).format('LL').toString();
return (
<EuiFlexGroup>
<EuiFlexItem css={tileStyle}>
<EuiText css={tileTitleTextStyle}>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTile.cvsScore"
defaultMessage="CVSS"
/>
</EuiText>
<div>
<CVSScoreBadge
version={vulnerability?.score?.version}
score={vulnerability?.score?.impact}
/>
</div>
</EuiFlexItem>
<EuiFlexItem css={tileStyle}>
<EuiText css={tileTitleTextStyle}>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTile.dataSource"
defaultMessage="Data Source"
/>
</EuiText>
<EuiLink href={vulnerability?.data_source?.URL} target="_blank">
{vulnerability?.data_source?.ID}
</EuiLink>
</EuiFlexItem>
<EuiFlexItem css={tileStyle}>
<EuiText css={tileTitleTextStyle}>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDate"
defaultMessage="Published Date"
/>
</EuiText>
<strong>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDateText"
defaultMessage="{date}"
values={{
date,
}}
/>
</strong>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const VulnerabilityOverviewTab = ({ vulnerability }: VulnerabilityTabProps) => {
const emptyFixesMessageState = i18n.translate(
'xpack.csp.vulnerabilities.vulnerabilityOverviewTab.emptyFixesMessage',
{
defaultMessage: 'No available fixes yet.',
}
);
const fixesDisplayText = vulnerability?.package?.fixed_version
? `${vulnerability?.package?.name} ${vulnerability?.package?.fixed_version}`
: emptyFixesMessageState;
const cvssScores: JSX.Element[] = Object.entries<VectorScoreBase>(vulnerability?.cvss).map(
([vendor, vectorScoreBase]: [string, VectorScoreBase]) => {
return (
<EuiFlexItem>
<CVSScore vectorBaseScore={vectorScoreBase} vendor={vendor} />
</EuiFlexItem>
);
}
);
const horizontalStyle = css`
margin-block: 12px;
`;
const flyoutSubheadingStyle = css`
font-size: ${euiThemeVars.euiFontSizeM};
line-height: 24px;
margin-bottom: ${euiThemeVars.euiSizeS};
font-weight: 600;
`;
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<VulnerabilityOverviewTiles vulnerability={vulnerability} />
</EuiFlexItem>
<EuiHorizontalRule css={horizontalStyle} />
<EuiFlexItem>
<h4 css={flyoutSubheadingStyle}>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTab.descriptionTitle"
defaultMessage="Description"
/>
</h4>
<EuiText>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTab.descriptionText"
defaultMessage="{description}"
values={{ description: vulnerability?.description }}
/>
</EuiText>
</EuiFlexItem>
<EuiHorizontalRule css={horizontalStyle} />
<EuiFlexItem>
<h4 css={flyoutSubheadingStyle}>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTab.fixes"
defaultMessage="Fixes"
/>
</h4>
<EuiText>{fixesDisplayText}</EuiText>
</EuiFlexItem>
<EuiHorizontalRule css={horizontalStyle} />
{cvssScores?.length > 0 && (
<EuiFlexItem>
<h4 css={flyoutSubheadingStyle}>
<FormattedMessage
id="xpack.csp.vulnerabilities.vulnerabilityOverviewTab.vulnerabilityScores"
defaultMessage="Vulnerability Scores"
/>
</h4>
<EuiFlexGroup
direction="column"
gutterSize="l"
css={css`
margin-top: ${euiThemeVars.euiSizeS};
`}
>
{cvssScores}
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};