UI – Vulnerability details page (#16665)

### _Draft until available to address feedback and merge on Monday –
ready for review_

## Addresses #16472 
- Full accounting of features in linked ticket

<img width="1676" alt="Screenshot 2024-02-07 at 10 24 03 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/aa0cd3b5-9191-4078-b57c-ee451dc5c632">

<img width="909" alt="Screenshot 2024-02-07 at 10 38 52 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/ea1b0067-bb91-4502-bde0-0e36914a0829">

## Checklist for submitter
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling 2024-02-08 08:56:32 -08:00 committed by mostlikelee
parent 0ec010976a
commit b9fc6968a5
28 changed files with 767 additions and 33 deletions

View File

@ -12,7 +12,7 @@ const DEFAULT_VULNERABILITY: IVulnerability = {
details_link: "https://nvd.nist.gov/vuln/detail/CVE-2022-30190",
cvss_score: 7.8, // Available in Fleet Premium
epss_probability: 0.9729, // Available in Fleet Premium
cisa_known_exploit: false, // Available in Fleet Premium
cisa_known_exploit: true, // Available in Fleet Premium
cve_published: "2022-06-01T00:15:00Z", // Available in Fleet Premium
cve_description:
"Microsoft Windows Support Diagnostic Tool (MSDT) Remote Code Execution Vulnerability.", // Available in Fleet Premium
@ -29,7 +29,20 @@ const DEFAULT_VULNERABILITY: IVulnerability = {
generated_cpes: [],
},
],
software: [],
software: [
{
id: 1,
name: "bad software",
version: "1.1.1",
bundle_identifier: "com.bad.software",
source: "apps",
generated_cpe: "cpe:/a:bad:software:1.1.1",
hosts_count: 5,
last_opened_at: "2021-08-18T15:11:35Z",
installed_paths: ["/Applications/BadSoftware.app"],
resolved_in_version: "2",
},
],
};
export const createMockVulnerability = (

View File

@ -1,6 +1,7 @@
.card {
border-radius: $border-radius;
border: 1px solid $ui-fleet-black-10;
// TODO - update this to be 40px per style guide
padding: $pad-large;
// radius styles

View File

@ -0,0 +1,18 @@
import { Meta, StoryObj } from "@storybook/react";
import DataSet from "./DataSet";
const meta: Meta<typeof DataSet> = {
title: "Components/DataSet",
component: DataSet,
args: {
title: "Data set",
value: "This is the value",
},
};
export default meta;
type Story = StoryObj<typeof DataSet>;
export const Basic: Story = {};

View File

@ -0,0 +1,19 @@
import React from "react";
const baseClass = "data-set";
interface IDataSetProps {
title: React.ReactNode;
value: React.ReactNode;
}
const DataSet = ({ title, value }: IDataSetProps) => {
return (
<div className={`${baseClass}`}>
<dt>{title}</dt>
<dd>{value}</dd>
</div>
);
};
export default DataSet;

View File

@ -0,0 +1,7 @@
.data-set {
font-size: $x-small;
dt {
font-weight: $bold;
}
}

View File

@ -0,0 +1 @@
export { default } from "./DataSet";

View File

@ -0,0 +1,63 @@
import React from "react";
import { uniqueId } from "lodash";
import ReactTooltip from "react-tooltip";
import { formatFloatAsPercentage } from "utilities/helpers";
import Icon from "components/Icon";
import { COLORS } from "styles/var/colors";
const baseClass = "probability-of-exploit";
interface IProbabilityOfExploit {
probabilityOfExploit: number;
cisaKnownExploit?: boolean | null;
tooltipPosition?: "top" | "bottom" | "left" | "right";
}
const ProbabilityOfExploit = ({
probabilityOfExploit,
cisaKnownExploit,
tooltipPosition = "top",
}: IProbabilityOfExploit) => {
const renderExploitedIcon = () => {
const tooltipId = uniqueId();
return (
<>
<span
className={`${baseClass} tooltip tooltip__tooltip-icon`}
data-tip
data-for={tooltipId}
>
<Icon name="error" size="small" color="status-error" />
</span>
<ReactTooltip
place={tooltipPosition}
effect="solid"
backgroundColor={COLORS["tooltip-bg"]}
id={tooltipId}
data-html
>
<span className={`tooltip__tooltip-text`}>
<>
The vulnerability has been actively exploited in the wild. This
data is reported by the Cybersecurity and Infrastructure Security
Agency (CISA).
</>
</span>
</ReactTooltip>
</>
);
};
return (
<span className={baseClass}>
{formatFloatAsPercentage(probabilityOfExploit)}
{cisaKnownExploit && renderExploitedIcon()}
</span>
);
};
export default ProbabilityOfExploit;

View File

@ -0,0 +1,5 @@
.probability-of-exploit {
display: flex;
align-items: center;
gap: 8px;
}

View File

@ -0,0 +1 @@
export { default } from "./ProbabilityOfExploit";

View File

@ -13,6 +13,8 @@ interface IHostLinkProps {
platformLabelId?: number;
/** Shows right chevron without text */
condensed?: boolean;
responsive?: boolean;
customText?: string;
/** Table links shows on row hover only */
rowHover?: boolean;
}
@ -24,6 +26,8 @@ const ViewAllHostsLink = ({
className,
platformLabelId,
condensed = false,
responsive = false,
customText,
rowHover = false,
}: IHostLinkProps): JSX.Element => {
const viewAllHostsLinkClass = classnames(baseClass, className, {
@ -40,7 +44,13 @@ const ViewAllHostsLink = ({
return (
<Link className={viewAllHostsLinkClass} to={path} title="host-link">
{!condensed && <span>View all hosts</span>}
{!condensed && (
<span
className={`${baseClass}__text${responsive ? "--responsive" : ""}`}
>
{customText ?? "View all hosts"}
</span>
)}
<Icon
name="chevron-right"
className={`${baseClass}__icon`}

View File

@ -1,3 +1,10 @@
.view-all-hosts-link {
@include table-link;
&__text {
&--responsive {
@media (max-width: $break-md) {
display: none;
}
}
}
}

View File

@ -39,7 +39,9 @@ export interface ISoftware {
installed_paths?: string[];
}
export type IVulnerabilitySoftware = Omit<ISoftware, "vulnerabilities">;
export type IVulnerabilitySoftware = Omit<ISoftware, "vulnerabilities"> & {
resolved_in_version: string;
};
export interface ISoftwareTitleVersion {
id: number;

View File

@ -0,0 +1,47 @@
import React, { useMemo } from "react";
import { IVulnerability } from "interfaces/vulnerability";
import Card from "components/Card";
import TableContainer from "components/TableContainer";
import generateColumnConfigs from "./SwVulnOSTableConfig";
const baseClass = "software-vuln-os-versions";
interface ISoftwareVulnOSVersions {
osVersions: IVulnerability["os_versions"];
isPremiumTier: boolean;
}
const SoftwareVulnOSVersions = ({
osVersions,
isPremiumTier,
}: ISoftwareVulnOSVersions) => {
const columnConfigs = useMemo(() => generateColumnConfigs(isPremiumTier), []);
const renderVulnerableOSTable = () => {
return (
<TableContainer
columnConfigs={columnConfigs}
data={osVersions}
defaultSortHeader="hosts"
defaultSortDirection="desc"
isClientSidePagination
resultsTitle={osVersions.length > 1 ? "items" : "item"}
isLoading={false} // not rendered otherwise
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
/>
);
};
return (
<Card borderRadiusSize="large" includeShadow className={baseClass}>
<h2>Vulnerable OS</h2>
{renderVulnerableOSTable()}
</Card>
);
};
export default SoftwareVulnOSVersions;

View File

@ -0,0 +1,88 @@
import React from "react";
import { Column } from "react-table";
import { IVulnerabilityOSVersion } from "interfaces/operating_system";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import PATHS from "router/paths";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import TextCell from "components/TableContainer/DataTable/TextCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
interface ICellProps {
row: {
original: IVulnerabilityOSVersion;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
const configs = [
{
Header: "Name",
disableSortBy: true,
accessor: "name_only",
Cell: ({ row }: ICellProps) => {
const { name, os_version_id, platform } = row.original;
return (
<LinkCell
path={PATHS.SOFTWARE_OS_DETAILS(os_version_id)}
value={
<>
<SoftwareIcon name={platform} />
<span className="os-version-name">{name}</span>
</>
}
/>
);
},
},
{
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: ({ cell }: INumberCellProps) => <TextCell value={cell.value} />,
},
{
Header: () => (
<>
Resolved in <div className="resolved-suffix">version</div>
</>
),
disableSortBy: true,
accessor: "resolved_in_version",
Cell: ({ cell }: INumberCellProps) => <TextCell value={cell.value} />,
},
{
Header: "Hosts",
disableSortBy: true,
accessor: "hosts_count",
Cell: ({ row }: ICellProps) => {
const { hosts_count, os_version_id } = row.original;
return (
<>
<TextCell value={hosts_count} />
<ViewAllHostsLink queryParams={{ os_version_id }} responsive />
</>
);
},
},
];
if (!isPremiumTier) {
return configs.filter(
(header) => header.accessor !== "resolved_in_version"
);
}
return configs;
};
export default generateColumnConfigs;

View File

@ -0,0 +1 @@
export { default } from "./SoftwareVulnOSVersions";

View File

@ -0,0 +1,47 @@
import React, { useMemo } from "react";
import { IVulnerability } from "interfaces/vulnerability";
import Card from "components/Card";
import TableContainer from "components/TableContainer";
import generateColumnConfigs from "./SwVulnSwTableConfig";
const baseClass = "software-vuln-software-versions";
interface ISoftwareVulnSoftwareVersions {
vulnSoftware: IVulnerability["software"];
isPremiumTier: boolean;
}
const SoftwareVulnSoftwareVersions = ({
vulnSoftware,
isPremiumTier,
}: ISoftwareVulnSoftwareVersions) => {
const columnConfigs = useMemo(() => generateColumnConfigs(isPremiumTier), []);
const renderVulnerableSoftwareTable = () => {
return (
<TableContainer
columnConfigs={columnConfigs}
data={vulnSoftware}
defaultSortHeader="hosts"
defaultSortDirection="desc"
isClientSidePagination
resultsTitle={vulnSoftware.length > 1 ? "items" : "item"}
isLoading={false} // not rendered otherwise
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
/>
);
};
return (
<Card borderRadiusSize="large" includeShadow className={`${baseClass}`}>
<h2>Vulnerable software</h2>
{renderVulnerableSoftwareTable()}
</Card>
);
};
export default SoftwareVulnSoftwareVersions;

View File

@ -0,0 +1,105 @@
import React from "react";
import { Column } from "react-table";
import { IVulnerabilitySoftware } from "interfaces/software";
import PATHS from "router/paths";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
interface ICellProps {
cell: {
value: number | string | IVulnerabilitySoftware;
};
row: {
original: IVulnerabilitySoftware;
};
}
interface IStringCellProps extends ICellProps {
cell: {
value: string;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
interface IVulnSoftwareCellProps extends ICellProps {
cell: {
value: IVulnerabilitySoftware;
};
}
const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
const configs = [
{
Header: "Name",
disableSortBy: true,
accessor: "name",
Cell: ({ row }: ICellProps) => {
const { name, id } = row.original;
return (
<LinkCell
path={PATHS.SOFTWARE_VERSION_DETAILS(id.toString())}
value={
<>
<SoftwareIcon name={name} />
<span className="vuln-sw-name">{name}</span>
</>
}
/>
);
},
},
{
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: ({ cell }: IStringCellProps) => <TextCell value={cell.value} />,
},
{
Header: () => (
<>
Resolved in <div className="resolved-suffix">version</div>
</>
),
disableSortBy: true,
accessor: "resolved_in_version",
Cell: ({ cell }: IStringCellProps) => <TextCell value={cell.value} />,
},
{
Header: "Hosts",
disableSortBy: true,
accessor: "hosts_count",
Cell: ({ row }: ICellProps) => {
const { hosts_count, id } = row.original;
return (
<>
<TextCell value={hosts_count} />
<ViewAllHostsLink
queryParams={{ software_title_id: id }}
responsive
/>
</>
);
},
},
];
if (!isPremiumTier) {
return configs.filter(
(header) => header.accessor !== "resolved_in_version"
);
}
return configs;
};
export default generateColumnConfigs;

View File

@ -0,0 +1 @@
export { default } from "./SoftwareVulnSoftwareVersions";

View File

@ -0,0 +1,101 @@
import React from "react";
import { IVulnerability } from "interfaces/vulnerability";
import CustomLink from "components/CustomLink";
import Card from "components/Card";
import DataSet from "components/DataSet";
import TooltipWrapper from "components/TooltipWrapper";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import ProbabilityOfExploit from "components/ProbabilityOfExploit";
import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip";
const baseClass = "software-vuln-summary";
interface ISoftwareVulnSummaryProps {
vuln: IVulnerability;
isPremiumTier: boolean;
}
const SoftwareVulnSummary = ({
vuln,
isPremiumTier,
}: ISoftwareVulnSummaryProps) => {
const {
cve,
details_link,
cve_description,
cvss_score,
epss_probability,
cisa_known_exploit,
cve_published,
created_at,
hosts_count,
} = vuln;
return (
<Card borderRadiusSize="large" includeShadow className={baseClass}>
<span className={`${baseClass}__header`}>
<h1>{cve}</h1>
<span className={`${baseClass}__header__links`}>
<CustomLink url={details_link} text="Visit NVD page" newTab />
<ViewAllHostsLink
customText="View affected hosts"
queryParams={{ cve }}
/>
</span>
</span>
{isPremiumTier && cve_description && (
<div className={`${baseClass}__description`}>{cve_description}</div>
)}
<dl className={`${baseClass}__description-list`}>
{isPremiumTier && (
<>
{cvss_score && (
<DataSet
title={
<TooltipWrapper tipContent="The worst case impact across different environments (CVSS base score). This data is reported by the National Vulnerability Database (NVD).">
Severity
</TooltipWrapper>
}
value={cvss_score}
/>
)}
{epss_probability && (
<DataSet
title={
<TooltipWrapper
tipContent="The probability that this vulnerability will be exploited in the next 30 days (EPSS probability). This data is reported by FIRST.org."
position="top-end"
>
Probability of exploit
</TooltipWrapper>
}
value={
<ProbabilityOfExploit
probabilityOfExploit={epss_probability}
cisaKnownExploit={cisa_known_exploit}
tooltipPosition="bottom"
/>
}
/>
)}
{cve_published && (
<DataSet
title="Published"
value={<HumanTimeDiffWithDateTip timeString={cve_published} />}
/>
)}
</>
)}
<DataSet
title="Detected"
value={<HumanTimeDiffWithDateTip timeString={created_at} />}
/>
<DataSet title="Affected hosts" value={hosts_count} />
</dl>
</Card>
);
};
export default SoftwareVulnSummary;

View File

@ -0,0 +1,46 @@
.software-vuln-summary {
display: flex;
flex-direction: column;
gap: $large;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
&__links {
display: flex;
gap: 1.2rem;
.component__tooltip-wrapper__underline {
top: 0;
}
}
h1 {
font-size: 1.2rem;
font-weight: $bold;
}
}
&__description {
font-size: $x-small;
}
&__description-list {
display: flex;
gap: 24px 40px;
flex-wrap: wrap;
.data-set {
height: 40px;
display: flex;
flex-direction: column;
justify-content: space-between;
.component__tooltip-wrapper__underline {
top: 0;
}
}
}
}

View File

@ -0,0 +1,96 @@
import React, { useContext } from "react";
import { useQuery } from "react-query";
import { RouteComponentProps } from "react-router";
import { AxiosError } from "axios";
import { AppContext } from "context/app";
import { IVulnerability } from "interfaces/vulnerability";
import softwareVulnAPI, {
IVulnerabilityResponse,
} from "services/entities/vulnerabilities";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import Fleet404 from "pages/errors/Fleet404";
import MainContent from "components/MainContent";
import SoftwareVulnSummary from "./SoftwareVulnSummary/SoftwareVulnSummary";
import SoftwareVulnOSVersions from "./SoftwareVulnOSVersions";
import SoftwareVulnSoftwareVersions from "./SoftwareVulnSoftwareVersions";
const baseClass = "software-vulnerability-details-page";
interface ISoftwareVulnerabilityDetailsRouteParams {
cve: string;
}
type ISoftwareVulnerabilityDetailsPageProps = RouteComponentProps<
undefined,
ISoftwareVulnerabilityDetailsRouteParams
>;
const SoftwareVulnerabilityDetailsPage = ({
router,
routeParams,
}: ISoftwareVulnerabilityDetailsPageProps) => {
const { isPremiumTier } = useContext(AppContext);
const cve = routeParams.cve;
const {
data: vuln,
isLoading: isVulnLoading,
isError: isVulnError,
error: vulnError,
} = useQuery<IVulnerabilityResponse, AxiosError, IVulnerability>(
["softwareVulnByCVE", cve],
() => softwareVulnAPI.getVulnerability(cve),
{
select: (data) => data.vulnerability,
}
);
const renderContent = () => {
if (isVulnLoading || !vuln) {
return <Spinner />;
}
if (isVulnError) {
// confirm okay to cast to AxiosError like this
if (vulnError.status === 404) {
return <Fleet404 />;
}
return <DataError />;
}
return (
<>
<SoftwareVulnSummary
vuln={vuln}
isPremiumTier={isPremiumTier ?? false}
/>
{!!vuln.os_versions && vuln.os_versions.length > 0 && (
<SoftwareVulnOSVersions
osVersions={vuln.os_versions}
isPremiumTier={isPremiumTier ?? false}
/>
)}
{!!vuln.software && vuln.software.length > 0 && (
<SoftwareVulnSoftwareVersions
vulnSoftware={vuln.software}
isPremiumTier={isPremiumTier ?? false}
/>
)}
</>
);
};
return (
<MainContent className={baseClass}>
<>{renderContent()}</>
</MainContent>
);
};
export default SoftwareVulnerabilityDetailsPage;

View File

@ -0,0 +1,73 @@
.software-vulnerability-details-page {
background-color: $ui-off-white;
@include page;
.card {
display: flex;
flex-direction: column;
gap: $pad-large;
padding: $pad-xxlarge;
}
h1,
h2 {
margin: 0;
font-weight: $bold;
}
h1 {
font-size: 1.2rem;
}
h2 {
font-size: 1rem;
}
.resolved-suffix {
display: inline;
@media (max-width: $break-md) {
display: none;
}
}
.table-container {
&__header {
margin-top: 0;
margin-bottom: $pad-medium;
}
&__results-count,
.results-count {
height: initial;
}
.data-table-block .data-table tbody {
tr {
.hosts_count__cell {
display: flex;
justify-content: space-between;
align-items: center;
// for showing and hiding "view all hosts" link on hover
.view-all-hosts-link {
opacity: 0;
transition: opacity 250ms;
&:hover {
opacity: 1;
}
}
.w250 {
min-width: initial;
height: min-content;
}
}
}
td {
.link-cell {
display: flex;
align-items: center;
gap: $pad-small;
}
}
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./SoftwareVulnerabilityDetailsPage";

View File

@ -3,26 +3,12 @@ import React from "react";
import { QueryParams } from "utilities/url";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import DataSet from "components/DataSet";
import SoftwareIcon from "../icons/SoftwareIcon";
const baseClass = "software-details-summary";
interface IDescriptionSetProps {
title: string;
value: React.ReactNode;
}
// TODO: move to frontend/components
const DataSet = ({ title, value }: IDescriptionSetProps) => {
return (
<div className={`${baseClass}__data-set`}>
<dt>{title}</dt>
<dd>{value}</dd>
</div>
);
};
interface ISoftwareDetailsSummaryProps {
title: string;
type?: string;

View File

@ -29,15 +29,4 @@
display: flex;
gap: $pad-xxlarge;
}
&__data-set {
font-size: $x-small;
display: flex;
flex-direction: column;
gap: $pad-xsmall;
dt {
font-weight: $bold;
}
}
}

View File

@ -65,6 +65,7 @@ import SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPag
import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage";
import TeamSettings from "pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings";
import SoftwareOSDetailsPage from "pages/SoftwarePage/SoftwareOSDetailsPage";
import SoftwareVulnerabilityDetailsPage from "pages/SoftwarePage/SoftwareVulnerabilityDetailsPage";
import PATHS from "router/paths";
@ -226,6 +227,10 @@ const routes = (
{/* This redirect keeps the old software/:id working */}
<Redirect from=":id" to="versions/:id" />
</Route>
<Route
path="vulnerabilities/:cve"
component={SoftwareVulnerabilityDetailsPage}
/>
<Route path="titles/:id" component={SoftwareTitleDetailsPage} />
<Route path="versions/:id" component={SoftwareVersionDetailsPage} />
<Route path="os/:id" component={SoftwareOSDetailsPage} />

View File

@ -61,10 +61,10 @@ export const getVulnerabilities = ({
});
};
const getVulnerability = (id: number): Promise<IVulnerabilityResponse> => {
const getVulnerability = (cve: string): Promise<IVulnerabilityResponse> => {
const { VULNERABILITY } = endpoints;
// return sendRequest("GET", VULNERABILITY(id)); // TODO: API INTEGRATION: uncomment when API is ready
// return sendRequest("GET", VULNERABILITY(cve)); // TODO: API INTEGRATION: uncomment when API is ready
return new Promise((resolve, reject) => {
resolve(createMockVulnerabilityResponse());
});

View File

@ -155,7 +155,8 @@ export default {
// Vulnerabilities endpoints
VULNERABILITIES: `/${API_VERSION}/fleet/vulnerabilities`,
VULNERABILITY: (id: number) => `/${API_VERSION}/fleet/vulnerabilities/${id}`,
VULNERABILITY: (cve: string) =>
`/${API_VERSION}/fleet/vulnerabilities/${cve}`,
// Script endpoints
HOST_SCRIPTS: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/scripts`,