Frontend: UI Vulnerabilities feature (#16322)

Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com>
This commit is contained in:
RachelElysia
2024-01-25 13:03:44 -05:00
committed by GitHub
co-authored by Gabriel Hernandez
parent 5b0360b517
commit 6e7174c22f
41 changed files with 1266 additions and 859 deletions
@@ -0,0 +1,36 @@
import { IOperatingSystemVersion } from "interfaces/operating_system";
import { IOSVersionsResponse } from "services/entities/operating_systems";
import { createMockSoftwareVulnerability } from "./softwareMock";
const DEFAULT_OS_VERSION: IOperatingSystemVersion = {
name: "Mac OS X",
name_only: "Mac OS X",
version: "10.15.7",
platform: "darwin",
hosts_count: 1,
generated_cpe: "cpe:/o:apple:mac_os_x:10.15.7",
vulnerabilities: [createMockSoftwareVulnerability()],
};
export const createMockOSVersion = (
overrides?: Partial<IOperatingSystemVersion>
): IOperatingSystemVersion => {
return { ...DEFAULT_OS_VERSION, ...overrides };
};
const DEFAULT_OS_VERSIONS_RESPONSE: IOSVersionsResponse = {
count: 1,
counts_updated_at: "2021-01-01T00:00:00Z",
os_versions: [createMockOSVersion()],
meta: {
has_next_results: false,
has_previous_results: false,
},
};
// eslint-disable-next-line import/prefer-default-export
export const createMockOSVersionsResponse = (
overrides?: Partial<IOSVersionsResponse>
): IOSVersionsResponse => {
return { ...DEFAULT_OS_VERSIONS_RESPONSE, ...overrides };
};
@@ -29,6 +29,8 @@ export interface ITableQueryData {
interface IRowProps extends Row {
original: {
id?: number;
name_only?: string; // Required for onSelectSingleRow of SoftwareOSTable.tsx
version?: string; // Required for onSelectSingleRow of SoftwareOSTable.tsx
};
}
@@ -12,7 +12,7 @@ export interface INavItem {
};
exclude?: boolean;
/** If `true`, this nav item will always navigate to the given `location.pathname`. This
* is useful when you always want to always naviate to a specific path no matter
* is useful when you want to always naviate to a specific path no matter
* which child page you are on (e.g. always navigate to /sofware/titles/ when
* clicking on the software nav item even if on /software/versions,
* software/titles/:id, or /software/versions/:id). Defaults to `undefined`.
+4 -1
View File
@@ -1,10 +1,13 @@
import { ISoftwareVulnerability } from "./software";
export interface IOperatingSystemVersion {
os_id: number;
name: string;
name_only: string;
version: string;
platform: string;
hosts_count: number;
generated_cpe: string;
vulnerabilities: ISoftwareVulnerability[];
}
export const OS_VENDOR_BY_PLATFORM: Record<string, string> = {
@@ -149,7 +149,7 @@ const OperatingSystems = ({
}, [isFetching, osInfo, setTitleDescription, setTitleDetail]);
const tableHeaders = useMemo(
() => generateTableHeaders(includeNameColumn, currentTeamId),
() => generateTableHeaders(currentTeamId, undefined, { includeName: true }),
[includeNameColumn, currentTeamId]
);
@@ -1,23 +1,49 @@
import React from "react";
import { Column } from "react-table";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import {
formatOperatingSystemDisplayName,
IOperatingSystemVersion,
} from "interfaces/operating_system";
import { ISoftwareVulnerability } from "interfaces/software";
import TextCell from "components/TableContainer/DataTable/TextCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import VulnerabilitiesCell from "pages/SoftwarePage/components/VulnerabilitiesCell";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
interface ICellProps {
cell: {
value: string;
value: number | string | ISoftwareVulnerability[];
};
row: {
original: IOperatingSystemVersion;
};
}
interface IStringCellProps extends ICellProps {
cell: {
value: string;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
interface IVulnCellProps extends ICellProps {
cell: {
value: ISoftwareVulnerability[];
};
}
interface IHeaderProps {
column: {
title: string;
@@ -25,46 +51,86 @@ interface IHeaderProps {
};
}
interface IDataColumn {
title: string;
Header: ((props: IHeaderProps) => JSX.Element) | string;
accessor: string;
Cell: (props: ICellProps) => JSX.Element;
disableHidden?: boolean;
disableSortBy?: boolean;
interface IOSTableConfigOptions {
includeName?: boolean;
includeVulnerabilities?: boolean;
includeIcon?: boolean;
}
const generateDefaultTableHeaders = (teamId?: number): IDataColumn[] => [
const generateDefaultTableHeaders = (
teamId?: number,
router?: InjectedRouter,
configOptions?: IOSTableConfigOptions
): Column[] => [
{
title: "Name",
Header: "Name",
disableSortBy: true,
accessor: "name_only",
Cell: ({ cell: { value } }: ICellProps) => (
<TextCell
value={value}
formatter={(name) => formatOperatingSystemDisplayName(name)}
/>
),
Cell: (cellProps: IStringCellProps) => {
if (!configOptions?.includeIcon) {
return (
<TextCell
value={cellProps.cell.value}
formatter={(name) => formatOperatingSystemDisplayName(name)}
/>
);
}
const { name, name_only, version } = cellProps.row.original;
console.log("cellProps.row.original", cellProps.row.original);
console.log("name_only, version", name_only, version);
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(PATHS.SOFTWARE_OS_DETAILS(name_only, version));
};
return (
<LinkCell
path={PATHS.SOFTWARE_OS_DETAILS(name_only, version)}
customOnClick={onClickSoftware}
value={
<>
<SoftwareIcon name={name} />
<span className="software-name">{name}</span>
</>
}
/>
);
},
},
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: ICellProps) => <TextCell value={cellProps.cell.value} />,
Cell: (cellProps: IStringCellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Hosts",
Header: (cellProps: IHeaderProps) => (
Header: "Vulnerabilities",
disableSortBy: true,
accessor: "vulnerabilities",
Cell: (cellProps: IVulnCellProps): JSX.Element => {
const platform = cellProps.row.original.platform;
if (platform !== "darwin" && platform !== "windows") {
return <TextCell value="Not supported" greyed />;
}
return <VulnerabilitiesCell vulnerabilities={cellProps.cell.value} />;
},
},
{
Header: (cellProps: IHeaderProps): JSX.Element => (
<HeaderCell
value={cellProps.column.title}
value="Hosts"
disableSortBy={false}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
accessor: "hosts_count",
Cell: (cellProps: ICellProps): JSX.Element => {
Cell: (cellProps: INumberCellProps): JSX.Element => {
const { hosts_count, name_only, version } = cellProps.row.original;
return (
<span className="hosts-cell__wrapper">
@@ -88,15 +154,25 @@ const generateDefaultTableHeaders = (teamId?: number): IDataColumn[] => [
];
const generateTableHeaders = (
includeName: boolean,
teamId?: number
): IDataColumn[] => {
if (!includeName) {
return generateDefaultTableHeaders(teamId).filter(
(column) => column.title !== "Name"
teamId?: number,
router?: InjectedRouter,
configOptions?: IOSTableConfigOptions
): Column[] => {
let tableConfig = generateDefaultTableHeaders(teamId, router, configOptions);
if (!configOptions?.includeName) {
tableConfig = tableConfig.filter(
(column) => column.accessor !== "name_only"
);
}
return generateDefaultTableHeaders(teamId);
if (!configOptions?.includeVulnerabilities) {
tableConfig = tableConfig.filter(
(column) => column.accessor !== "vulnerabilities"
);
}
return tableConfig;
};
export default generateTableHeaders;
@@ -0,0 +1,83 @@
import React from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
import {
IGetOSVersionsQueryKey,
IOSVersionsResponse,
getOSVersions,
} from "services/entities/operating_systems";
import TableDataError from "components/DataError";
import SoftwareOSTable from "./SoftwareOSTable";
const baseClass = "software-os";
interface ISoftwareOSProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
currentPage: number;
teamId?: number;
}
const SoftwareOS = ({
router,
isSoftwareEnabled,
perPage,
orderDirection,
orderKey,
currentPage,
teamId,
}: ISoftwareOSProps) => {
const queryParams = {
page: currentPage,
per_page: perPage,
order_direction: orderDirection,
order_key: orderKey,
teamId,
};
const { data, isFetching, isError } = useQuery<
IOSVersionsResponse,
Error,
IOSVersionsResponse,
IGetOSVersionsQueryKey[]
>(
[
{
scope: "software-os",
...queryParams,
},
],
() => getOSVersions(queryParams),
{
keepPreviousData: true,
staleTime: 30000,
}
);
if (isError) {
return <TableDataError className={`${baseClass}__table-error`} />;
}
return (
<div className={baseClass}>
<SoftwareOSTable
router={router}
data={data}
isSoftwareEnabled={isSoftwareEnabled}
perPage={perPage}
orderDirection={orderDirection}
orderKey={orderKey}
currentPage={currentPage}
teamId={teamId}
isLoading={isFetching}
/>
</div>
);
};
export default SoftwareOS;
@@ -0,0 +1,209 @@
import React, { useCallback, useContext, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { Row } from "react-table";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import CustomLink from "components/CustomLink";
import TableContainer from "components/TableContainer";
import LastUpdatedText from "components/LastUpdatedText";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
import { IOSVersionsResponse } from "services/entities/operating_systems";
import generateTableConfig from "pages/DashboardPage/cards/OperatingSystems/OperatingSystemsTableConfig";
import { buildQueryStringFromParams } from "utilities/url";
import { getNextLocationPath } from "utilities/helpers";
const baseClass = "software-os-table";
interface IRowProps extends Row {
original: {
version?: string;
name_only?: string;
};
}
interface ISoftwareOSTableProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
data?: IOSVersionsResponse;
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
currentPage: number;
teamId?: number;
isLoading: boolean;
}
const SoftwareOSTable = ({
router,
isSoftwareEnabled,
data,
perPage,
orderDirection,
orderKey,
currentPage,
teamId,
isLoading,
}: ISoftwareOSTableProps) => {
const { isSandboxMode, noSandboxHosts } = useContext(AppContext);
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
switch (key) {
case "sortDirection":
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "pageIndex":
return val !== currentPage;
default:
return false;
}
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData, changedParam: string) => {
return {
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
};
},
[teamId]
);
const onQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
// we want to determine which query param has changed in order to
// reset the page index to 0 if any other param has changed.
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_OS,
routeTemplate: "",
queryParams: generateNewQueryParams(newTableQuery, changedParam),
});
router.replace(newRoute);
},
[determineQueryParamChange, generateNewQueryParams, router]
);
const softwareTableHeaders = useMemo(() => {
if (!data) return [];
return generateTableConfig(teamId, router, {
includeName: true,
includeVulnerabilities: true,
includeIcon: true,
});
}, [data, router, teamId]);
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = {
os_version: row.original.version,
os_name: row.original.name_only,
team_id: teamId,
};
const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
hostsBySoftwareParams
)}`;
router.push(path);
};
const getItemsCountText = () => {
const count = data?.count;
if (!data?.os_versions || !count) return "";
return count === 1 ? `${count} item` : `${count} items`;
};
const getLastUpdatedText = () => {
if (!data?.os_versions || !data?.counts_updated_at) return "";
return (
<LastUpdatedText
lastUpdatedAt={data.counts_updated_at}
whatToRetrieve={"software"}
/>
);
};
const renderSoftwareCount = () => {
const itemText = getItemsCountText();
const lastUpdatedText = getLastUpdatedText();
if (!itemText) return null;
return (
<div className={`${baseClass}__count`}>
<span>{itemText}</span>
{lastUpdatedText}
</div>
);
};
const renderTableFooter = () => {
return (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
};
return (
<div className={baseClass}>
<TableContainer
columnConfigs={softwareTableHeaders}
data={data?.os_versions ?? []}
isLoading={isLoading}
resultsTitle={"items"}
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isSandboxMode={isSandboxMode}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}
defaultPageIndex={currentPage}
manualSortBy
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!data?.meta.has_next_results}
searchable={false}
onQueryChange={onQueryChange}
stackControls
renderCount={renderSoftwareCount}
renderFooter={renderTableFooter}
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
/>
</div>
);
};
export default SoftwareOSTable;
@@ -0,0 +1,40 @@
.software-os-table {
&__count {
display: flex;
gap: 12px;
}
.hosts-cell__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
}
.os-hosts-link {
opacity: 0;
transition: 250ms;
}
tr:hover {
.os-hosts-link {
opacity: 1;
}
}
.table-container {
&__data-table-block {
.data-table-block {
.data-table__table {
tbody {
.link-cell {
display: flex;
align-items: center;
gap: $pad-small;
}
}
}
}
}
}
}
@@ -0,0 +1 @@
export { default } from "./SoftwareOSTable";
@@ -0,0 +1,3 @@
.software-os {
}
@@ -0,0 +1 @@
export { default } from "./SoftwareOS";
@@ -0,0 +1,121 @@
import React from "react";
import { useQuery } from "react-query";
import osVersionsAPI, {
IOSVersionsResponse,
} from "services/entities/operating_systems";
import { IOperatingSystemVersion } from "interfaces/operating_system";
import { SUPPORT_LINK } from "utilities/constants";
import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import MainContent from "components/MainContent";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
import SoftwareVulnerabilitiesTable from "../components/SoftwareVulnerabilitiesTable";
const baseClass = "software-os-details-page";
interface INotSupportedVulnProps {
platform: string;
}
const NotSupportedVuln = ({ platform }: INotSupportedVulnProps) => {
return (
<EmptyTable
header="Vulnerabilities are not supported for this type of host"
info={
<>
Interested in vulnerability management for{" "}
{platform === "chrome" ? "Chromebooks" : "Linux hosts"}?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</>
}
/>
);
};
interface ISoftwareOSDetailsPageProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
location: { query: { name: string; version: string } }; // no type in react-router v3
}
const SoftwareOSDetailsPage = ({ location }: ISoftwareOSDetailsPageProps) => {
const name = location.query.name;
const osVersion = location.query.version;
const { data: osVersionDetails, isLoading, isError } = useQuery<
IOSVersionsResponse,
Error,
IOperatingSystemVersion
>(
["osVersionDetails", name, osVersion],
() => osVersionsAPI.getOSVersion({ os_name: name, os_version: osVersion }),
{
select: (res) => res.os_versions[0],
}
);
const renderTable = () => {
if (!osVersionDetails) {
return null;
}
if (
osVersionDetails.platform !== "darwin" &&
osVersionDetails.platform !== "windows"
) {
return <NotSupportedVuln platform={osVersionDetails.platform} />;
}
return (
<SoftwareVulnerabilitiesTable
data={osVersionDetails.vulnerabilities}
itemName="version"
isLoading={isLoading}
/>
);
};
const renderContent = () => {
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <TableDataError className={`${baseClass}__table-error`} />;
}
if (!osVersionDetails) {
return null;
}
return (
<>
<SoftwareDetailsSummary
title={`${osVersionDetails.name} ${osVersionDetails.version}`}
hosts={osVersionDetails.hosts_count}
queryParams={{
os_name: osVersionDetails.name_only,
os_version: osVersionDetails.version,
}}
name={osVersionDetails.name}
/>
{/* TODO: can we use Card here for card styles */}
<div className={`${baseClass}__vulnerabilities-section`}>
<h2>Vulnerabilities</h2>
{renderTable()}
</div>
</>
);
};
return (
<MainContent className={baseClass}>
<>{renderContent()}</>
</MainContent>
);
};
export default SoftwareOSDetailsPage;
@@ -0,0 +1,19 @@
.software-os-details-page {
background-color: $ui-off-white;
display: flex;
flex-direction: column;
gap: $pad-medium;
&__vulnerabilities-section {
background-color: $core-white;
padding: $pad-xxlarge;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-xxlarge;
box-shadow: $box-shadow;
h2 {
margin: 0;
font-size: $medium;
}
}
}
@@ -0,0 +1 @@
export { default } from "./SoftwareOSDetailsPage";
+12 -6
View File
@@ -39,13 +39,18 @@ const softwareSubNav: ISoftwareSubNavItem[] = [
pathname: PATHS.SOFTWARE_TITLES,
},
{
name: "Versions",
pathname: PATHS.SOFTWARE_VERSIONS,
name: "OS",
pathname: PATHS.SOFTWARE_OS,
},
];
const getTabIndex = (path: string): number => {
return softwareSubNav.findIndex((navItem) => {
// This check ensures that for software versions path we still
// highlight the software tab.
if (navItem.name === "Software" && PATHS.SOFTWARE_VERSIONS === path) {
return true;
}
// tab stays highlighted for paths that start with same pathname
return path.startsWith(navItem.pathname);
});
@@ -107,7 +112,6 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const queryParams = location.query;
// initial values for query params used on this page
const query = queryParams && queryParams.query ? queryParams.query : "";
const sortHeader =
queryParams && queryParams.order_key
? queryParams.order_key
@@ -120,6 +124,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
queryParams && queryParams.page
? parseInt(queryParams.page, 10)
: DEFAULT_PAGE;
// TODO: move these down into the Software Titles component.
const query = queryParams && queryParams.query ? queryParams.query : "";
const showVulnerableSoftware =
queryParams !== undefined && queryParams.vulnerable === "true";
@@ -322,14 +328,14 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
isSoftwareEnabled: Boolean(
softwareConfig?.features?.enable_software_inventory
),
query,
// NOTE: may move this lower in tree if we need different values for different pages
perPage: DEFAULT_PAGE_SIZE,
orderDirection: sortDirection,
orderKey: sortHeader,
showVulnerableSoftware,
currentPage: page,
teamId: teamIdForApi,
// TODO: move down into the Software Titles component
query,
showVulnerableSoftware,
})}
</div>
);
@@ -60,12 +60,11 @@ const SoftwareTitleDetailsPage = ({
return (
<>
<SoftwareDetailsSummary
id={softwareId}
title={softwareTitle.name}
type={formatSoftwareType(softwareTitle)}
versions={softwareTitle.versions.length}
hosts={softwareTitle.hosts_count}
queryParam="software_title_id"
queryParams={{ software_title_id: softwareId }}
name={softwareTitle.name}
source={softwareTitle.source}
/>
@@ -0,0 +1,333 @@
import React, { useCallback, useContext, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { Row } from "react-table";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { getNextLocationPath } from "utilities/helpers";
import {
GITHUB_NEW_ISSUE_LINK,
VULNERABLE_DROPDOWN_OPTIONS,
} from "utilities/constants";
import { buildQueryStringFromParams } from "utilities/url";
import {
ISoftwareTitlesResponse,
ISoftwareVersionsResponse,
} from "services/entities/software";
import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import TableContainer from "components/TableContainer";
import Slider from "components/forms/fields/Slider";
import CustomLink from "components/CustomLink";
import LastUpdatedText from "components/LastUpdatedText";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
import generateTitlesTableConfig from "./SoftwareTitlesTableConfig";
import generateVersionsTableConfig from "./SoftwareVersionsTableConfig";
interface IRowProps extends Row {
original: {
id?: number;
};
}
type ITableConfigGenerator = (router: InjectedRouter, teamId?: number) => void;
const isSoftwareTitles = (
data?: ISoftwareTitlesResponse | ISoftwareVersionsResponse
): data is ISoftwareTitlesResponse => {
if (!data) return false;
return (data as ISoftwareTitlesResponse).software_titles !== undefined;
};
interface ISoftwareTableProps {
router: InjectedRouter;
data?: ISoftwareTitlesResponse | ISoftwareVersionsResponse;
showVersions: boolean;
isSoftwareEnabled: boolean;
query: string;
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
showVulnerableSoftware: boolean;
currentPage: number;
teamId?: number;
isLoading: boolean;
}
const baseClass = "software-table";
const SoftwareTable = ({
router,
data,
showVersions,
isSoftwareEnabled,
query,
perPage,
orderDirection,
orderKey,
showVulnerableSoftware,
currentPage,
teamId,
isLoading,
}: ISoftwareTableProps) => {
const { isSandboxMode, noSandboxHosts } = useContext(AppContext);
const currentPath = showVersions
? PATHS.SOFTWARE_VERSIONS
: PATHS.SOFTWARE_TITLES;
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
switch (key) {
case "searchQuery":
return val !== query;
case "sortDirection":
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "vulnerable":
return val !== showVulnerableSoftware.toString();
case "pageIndex":
return val !== currentPage;
default:
return false;
}
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey, query, showVulnerableSoftware]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData, changedParam: string) => {
return {
query: newTableQuery.searchQuery,
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
vulnerable: showVulnerableSoftware.toString(),
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
};
},
[showVulnerableSoftware, teamId]
);
// NOTE: this is called once on initial render and every time the query changes
const onQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
// we want to determine which query param has changed in order to
// reset the page index to 0 if any other param has changed.
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
pathPrefix: currentPath,
routeTemplate: "",
queryParams: generateNewQueryParams(newTableQuery, changedParam),
});
router.replace(newRoute);
},
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
);
let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
let generateTableConfig: ITableConfigGenerator;
if (data === undefined) {
tableData;
generateTableConfig = () => [];
} else if (isSoftwareTitles(data)) {
tableData = data.software_titles;
generateTableConfig = generateTitlesTableConfig;
} else {
tableData = data.software;
generateTableConfig = generateVersionsTableConfig;
}
const softwareTableHeaders = useMemo(() => {
if (!data) return [];
return generateTableConfig(router, teamId);
}, [generateTableConfig, data, router, teamId]);
// determines if a user be able to search in the table
const searchable =
isSoftwareEnabled &&
(!!tableData || query !== "" || showVulnerableSoftware);
const getItemsCountText = () => {
const count = data?.count;
if (!tableData || !count) return "";
return count === 1 ? `${count} item` : `${count} items`;
};
const getLastUpdatedText = () => {
if (!tableData || !data?.counts_updated_at) return "";
return (
<LastUpdatedText
lastUpdatedAt={data.counts_updated_at}
whatToRetrieve={"software"}
/>
);
};
const handleShowVersionsToggle = () => {
router.replace(
getNextLocationPath({
pathPrefix: showVersions
? PATHS.SOFTWARE_TITLES
: PATHS.SOFTWARE_VERSIONS,
routeTemplate: "",
queryParams: {
query,
teamId,
orderDirection,
orderKey,
vulnerable: showVulnerableSoftware.toString(),
page: 0, // resets page index
},
})
);
};
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
router.replace(
getNextLocationPath({
pathPrefix: currentPath,
routeTemplate: "",
queryParams: {
query,
teamId,
orderDirection,
orderKey,
vulnerable: isFilterVulnerable,
page: 0, // resets page index
},
})
);
};
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = showVersions
? {
software_version_id: row.original.id,
team_id: teamId,
}
: {
software_title_id: row.original.id,
team_id: teamId,
};
const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
hostsBySoftwareParams
)}`;
router.push(path);
};
const renderSoftwareCount = () => {
const itemText = getItemsCountText();
const lastUpdatedText = getLastUpdatedText();
if (!itemText) return null;
return (
<div className={`${baseClass}__count`}>
<span>{itemText}</span>
{lastUpdatedText}
</div>
);
};
const renderCustomFilters = () => {
return (
<div className={`${baseClass}__filter-controls`}>
<div className={`${baseClass}__version-slider`}>
{/* div required dropdown form field width bug */}
<Slider
value={showVersions}
onChange={handleShowVersionsToggle}
inactiveText="Show versions"
activeText="Show versions"
/>
</div>
<Dropdown
value={showVulnerableSoftware}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
searchable={false}
onChange={handleVulnFilterDropdownChange}
tableFilterDropdown
/>
</div>
);
};
const renderTableFooter = () => {
return (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
};
return (
<div className={baseClass}>
<TableContainer
columnConfigs={softwareTableHeaders}
data={tableData ?? []}
isLoading={isLoading}
resultsTitle={"items"}
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isFilterVulnerable={showVulnerableSoftware}
isSandboxMode={isSandboxMode}
isCollectingSoftware={false} // TODO: update with new API
isSearching={query !== ""}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}
defaultPageIndex={currentPage}
defaultSearchQuery={query}
manualSortBy
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!data?.meta.has_next_results}
searchable={searchable}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
onQueryChange={onQueryChange}
// additionalQueries serves as a trigger for the useDeepEffect hook
// to fire onQueryChange for events happeing outside of
// the TableContainer.
additionalQueries={showVulnerableSoftware ? "vulnerable" : ""}
customControl={searchable ? renderCustomFilters : undefined}
stackControls
renderCount={renderSoftwareCount}
renderFooter={renderTableFooter}
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
/>
</div>
);
};
export default SoftwareTable;
@@ -13,9 +13,9 @@ import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import VersionCell from "../components/VersionCell";
import VulnerabilitiesCell from "../components/VulnerabilitiesCell";
import SoftwareIcon from "../components/icons/SoftwareIcon";
import VersionCell from "../../components/VersionCell";
import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
import SoftwareIcon from "../../components/icons/SoftwareIcon";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
@@ -37,9 +37,6 @@ interface IVersionCellProps extends ICellProps {
cell: {
value: ISoftwareTitleVersion[];
};
row: {
original: ISoftwareTitle;
};
}
interface INumberCellProps extends ICellProps {
@@ -61,6 +58,9 @@ interface IHeaderProps {
}
const getVulnerabilities = (versions: ISoftwareTitleVersion[]) => {
if (!versions) {
return [];
}
const vulnerabilities = versions.reduce((acc: string[], currentVersion) => {
if (
currentVersion.vulnerabilities &&
@@ -13,8 +13,8 @@ import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import VulnerabilitiesCell from "../components/VulnerabilitiesCell";
import SoftwareIcon from "../components/icons/SoftwareIcon";
import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
import SoftwareIcon from "../../components/icons/SoftwareIcon";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
@@ -58,8 +58,6 @@ interface IHeaderProps {
const generateTableHeaders = (
router: InjectedRouter,
isPremiumTier?: boolean,
isSandboxMode?: boolean,
teamId?: number
): Column[] => {
const softwareTableHeaders = [
@@ -1,6 +1,4 @@
.software-versions {
margin-top: $pad-xxlarge;
.software-table {
&__count {
display: flex;
gap: 12px;
@@ -26,6 +24,17 @@
}
}
&__filter-controls {
display: flex;
align-items: center;
gap: $pad-medium;
}
// Fix dropdown form bug
&__version-slider {
width: 210px;
}
.table-container {
&__header {
flex-direction: column-reverse; // Search bar on top
@@ -66,7 +75,6 @@
&__data-table-block {
.data-table-block {
.data-table__table {
// for showing and hiding "view all hosts" link on hover
tr {
.software-link {
@@ -93,7 +101,7 @@
@media (min-width: $break-lg) {
// expand the width of version header at larger screen sizes
.version__header {
.versions__header {
width: $col-md;
}
}
@@ -122,7 +130,6 @@
gap: $pad-small;
}
.hosts_count__cell {
.hosts-cell__wrapper {
display: flex;
@@ -143,7 +150,7 @@
}
@media (min-width: $break-lg) {
.version__cell {
.versions__cell {
width: $col-md;
}
}
@@ -159,8 +166,4 @@
overflow-x: auto;
}
}
&__table-error {
margin-top: $pad-xxxlarge;
}
}
@@ -0,0 +1 @@
export { default } from "./SoftwareTable";
@@ -1,45 +1,34 @@
import React, { useCallback, useContext, useMemo } from "react";
import React from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { Row } from "react-table";
import PATHS from "router/paths";
import softwareAPI, {
ISoftwareApiParams,
ISoftwareTitlesResponse,
ISoftwareVersionsResponse,
} from "services/entities/software";
import { AppContext } from "context/app";
import {
GITHUB_NEW_ISSUE_LINK,
VULNERABLE_DROPDOWN_OPTIONS,
} from "utilities/constants";
import { getNextLocationPath } from "utilities/helpers";
import { buildQueryStringFromParams } from "utilities/url";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import LastUpdatedText from "components/LastUpdatedText";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import EmptySoftwareTable from "../components/EmptySoftwareTable";
import generateSoftwareTitlesTableHeaders from "./SoftwareTitlesTableConfig";
import SoftwareTable from "./SoftwareTable";
const baseClass = "software-titles";
interface IRowProps extends Row {
original: {
id?: number;
};
}
const DATA_STALE_TIME = 30000;
const QUERY_OPTIONS = {
keepPreviousData: true,
staleTime: DATA_STALE_TIME,
};
interface ISoftwareTitlesQueryKey extends ISoftwareApiParams {
scope: "software-titles";
}
interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
scope: "software-versions";
}
interface ISoftwareTitlesProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
@@ -63,13 +52,13 @@ const SoftwareTitles = ({
currentPage,
teamId,
}: ISoftwareTitlesProps) => {
const { isSandboxMode, noSandboxHosts } = useContext(AppContext);
const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS;
// request to get software data
const {
data: softwareData,
isLoading: isSoftwareLoading,
isError: isSoftwareError,
data: titlesData,
isFetching: isTitlesFetching,
isError: isTitlesError,
} = useQuery<
ISoftwareTitlesResponse,
Error,
@@ -90,211 +79,64 @@ const SoftwareTitles = ({
],
({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]),
{
// stale time can be adjusted if fresher data is desired based on
// software inventory interval
staleTime: 30000,
...QUERY_OPTIONS,
enabled: location.pathname === PATHS.SOFTWARE_TITLES,
}
);
// determines if a user be able to search in the table
const searchable =
isSoftwareEnabled &&
(!!softwareData?.software_titles || query !== "" || showVulnerableSoftware);
const softwareTableHeaders = useMemo(
() => generateSoftwareTitlesTableHeaders(router, teamId),
[router, teamId]
// request to get software versions data
const {
data: versionsData,
isFetching: isVersionsFetching,
isError: isVersionsError,
} = useQuery<
ISoftwareVersionsResponse,
Error,
ISoftwareVersionsResponse,
ISoftwareVersionsQueryKey[]
>(
[
{
scope: "software-versions",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
vulnerable: showVulnerableSoftware,
},
],
({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]),
{
...QUERY_OPTIONS,
enabled: location.pathname === PATHS.SOFTWARE_VERSIONS,
}
);
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
router.replace(
getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_TITLES,
routeTemplate: "",
queryParams: {
query,
teamId,
orderDirection,
orderKey,
vulnerable: isFilterVulnerable,
page: 0, // resets page index
},
})
);
};
if (isTitlesFetching) {
return <Spinner />;
}
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = {
software_title_id: row.original.id,
team_id: teamId,
};
const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
hostsBySoftwareParams
)}`;
router.push(path);
};
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
switch (key) {
case "searchQuery":
return val !== query;
case "sortDirection":
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "vulnerable":
return val !== showVulnerableSoftware.toString();
case "pageIndex":
return val !== currentPage;
default:
return false;
}
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey, query, showVulnerableSoftware]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData, changedParam: string) => {
return {
query: newTableQuery.searchQuery,
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
vulnerable: showVulnerableSoftware.toString(),
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
};
},
[showVulnerableSoftware, teamId]
);
// NOTE: this is called once on initial render and every time the query changes
const onQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
// we want to determine which query param has changed in order to
// reset the page index to 0 if any other param has changed.
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_TITLES,
routeTemplate: "",
queryParams: generateNewQueryParams(newTableQuery, changedParam),
});
router.replace(newRoute);
},
[determineQueryParamChange, generateNewQueryParams, router]
);
const getItemsCountText = () => {
const count = softwareData?.count;
if (!softwareData || !count) return "";
return count === 1 ? `${count} item` : `${count} items`;
};
const getLastUpdatedText = () => {
if (!softwareData || !softwareData.counts_updated_at) return "";
return (
<LastUpdatedText
lastUpdatedAt={softwareData.counts_updated_at}
whatToRetrieve={"software"}
/>
);
};
const renderSoftwareCount = () => {
const itemText = getItemsCountText();
const lastUpdatedText = getLastUpdatedText();
if (!itemText) return null;
return (
<div className={`${baseClass}__count`}>
<span>{itemText}</span>
{lastUpdatedText}
</div>
);
};
const renderVulnFilterDropdown = () => {
return (
<Dropdown
value={showVulnerableSoftware}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
searchable={false}
onChange={handleVulnFilterDropdownChange}
tableFilterDropdown
/>
);
};
const renderTableFooter = () => {
return (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
};
if (isSoftwareError) {
if (isTitlesError || isVersionsError) {
return <TableDataError className={`${baseClass}__table-error`} />;
}
return (
<div className={baseClass}>
<TableContainer
columnConfigs={softwareTableHeaders}
data={softwareData?.software_titles || []}
isLoading={isSoftwareLoading}
resultsTitle={"items"}
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isFilterVulnerable={showVulnerableSoftware}
isSandboxMode={isSandboxMode}
isCollectingSoftware={false} // TODO: update with new API
isSearching={query !== ""}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}
defaultPageIndex={currentPage}
defaultSearchQuery={query}
manualSortBy
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!softwareData?.meta.has_next_results}
searchable={searchable}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
onQueryChange={onQueryChange}
// additionalQueries serves as a trigger for the useDeepEffect hook
// to fire onQueryChange for events happeing outside of
// the TableContainer.
additionalQueries={showVulnerableSoftware ? "vulnerable" : ""}
customControl={searchable ? renderVulnFilterDropdown : undefined}
stackControls
renderCount={renderSoftwareCount}
renderFooter={renderTableFooter}
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
<SoftwareTable
router={router}
data={showVersions ? versionsData : titlesData}
showVersions={showVersions}
isSoftwareEnabled={isSoftwareEnabled}
query={query}
perPage={perPage}
orderDirection={orderDirection}
orderKey={orderKey}
showVulnerableSoftware={showVulnerableSoftware}
currentPage={currentPage}
teamId={teamId}
isLoading={isTitlesFetching || isVersionsFetching}
/>
</div>
);
@@ -1,164 +1,6 @@
.software-titles {
margin-top: $pad-xxlarge;
&__count {
display: flex;
gap: 12px;
}
&__vuln_dropdown {
.Select-menu-outer {
width: 250px;
max-height: 310px;
.Select-menu {
max-height: none;
}
}
.Select-value {
padding-left: $pad-medium;
padding-right: $pad-medium;
}
.dropdown__custom-value-label {
width: 155px; // Override 105px for longer text options
}
}
.table-container {
&__header {
flex-direction: column-reverse; // Search bar on top
margin-bottom: $pad-medium;
@media (min-width: $break-md) {
flex-direction: row;
}
}
&__header-left {
flex-direction: row; // Filter dropdown aligned with count
.controls {
.form-field--dropdown {
margin: 0;
}
}
}
&__search-input,
&__search {
width: 100%; // Search bar across entire table
.input-icon-field__input {
width: 100%;
}
@media (min-width: $break-md) {
width: auto;
.input-icon-field__input {
width: 375px;
}
}
}
&__data-table-block {
.data-table-block {
.data-table__table {
// for showing and hiding "view all hosts" link on hover
tr {
.software-link {
opacity: 0;
transition: opacity 250ms;
}
&:hover {
.software-link {
opacity: 1;
}
}
}
thead {
.name__header {
width: $col-md;
}
.hosts_count__header {
width: auto;
border-right: 0;
}
@media (min-width: $break-lg) {
// expand the width of version header at larger screen sizes
.versions__header {
width: $col-md;
}
}
}
tbody {
.name__cell {
max-width: $col-md;
// Tooltip does not get cut off
.children-wrapper {
overflow: initial;
}
// ellipsis for software name
.software-name {
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
}
}
.link-cell {
display: flex;
align-items: center;
gap: $pad-small;
}
.hosts_count__cell {
.hosts-cell__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
.hosts-cell__link {
display: flex;
white-space: nowrap;
}
}
}
@media (min-width: $break-sm) {
.name__cell {
max-width: $col-lg;
}
}
@media (min-width: $break-lg) {
.versions__cell {
width: $col-md;
}
}
}
}
}
}
}
// needed to handle overflow of the table data on small screens
.data-table {
&__wrapper {
overflow-x: auto;
}
}
&__table-error {
margin-top: $pad-xxxlarge;
}
@@ -1,4 +1,4 @@
import React, { useContext, useMemo } from "react";
import React from "react";
import { useQuery } from "react-query";
import { RouteComponentProps } from "react-router";
@@ -10,18 +10,13 @@ import hostsCountAPI, {
IHostsCountResponse,
} from "services/entities/host_count";
import { ISoftwareVersion, formatSoftwareType } from "interfaces/software";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import { AppContext } from "context/app";
import MainContent from "components/MainContent";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import EmptyTable from "components/EmptyTable";
import TableDataError from "components/DataError";
import Spinner from "components/Spinner";
import generateSoftwareVersionDetailsTableConfig from "./SoftwareVersionDetailsTableConfig";
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
import SoftwareVulnerabilitiesTable from "../components/SoftwareVulnerabilitiesTable";
const baseClass = "software-version-details-page";
@@ -34,29 +29,10 @@ type ISoftwareTitleDetailsPageProps = RouteComponentProps<
ISoftwareVersionDetailsRouteParams
>;
const NoVulnsDetected = (): JSX.Element => {
return (
<EmptyTable
header="No vulnerabilities detected for this software item."
info={
<>
Expecting to see vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</>
}
/>
);
};
const SoftwareVersionDetailsPage = ({
routeParams,
}: ISoftwareTitleDetailsPageProps) => {
const versionId = parseInt(routeParams.id, 10);
const { isPremiumTier, isSandboxMode } = useContext(AppContext);
const {
data: softwareVersion,
@@ -70,12 +46,12 @@ const SoftwareVersionDetailsPage = ({
}
);
// TODO: Confirm desired UX for error and loading states
const {
data: hostsCount,
// isError: isHostsCountError,
// isLoading: isHostsCountLoading,
} = useQuery<IHostsCountResponse, Error, number, IHostsCountQueryKey[]>(
const { data: hostsCount } = useQuery<
IHostsCountResponse,
Error,
number,
IHostsCountQueryKey[]
>(
[{ scope: "hosts_count", softwareVersionId: versionId }],
({ queryKey }) => hostsCountAPI.load(queryKey[0]),
{
@@ -85,15 +61,6 @@ const SoftwareVersionDetailsPage = ({
}
);
const tableHeaders = useMemo(
() =>
generateSoftwareVersionDetailsTableConfig(
Boolean(isPremiumTier),
Boolean(isSandboxMode)
),
[isPremiumTier, isSandboxMode]
);
const renderContent = () => {
if (isSoftwareVersionLoading) {
return <Spinner />;
@@ -110,35 +77,20 @@ const SoftwareVersionDetailsPage = ({
return (
<>
<SoftwareDetailsSummary
id={softwareVersion.id}
title={`${softwareVersion.name}, ${softwareVersion.version}`}
type={formatSoftwareType(softwareVersion)}
hosts={hostsCount ?? 0}
queryParam="software_version_id"
queryParams={{ software_version_id: softwareVersion.id }}
name={softwareVersion.name}
source={softwareVersion.source}
/>
<div className={`${baseClass}__vulnerabilities-section`}>
<h2 className="section__header">Vulnerabilities</h2>
{softwareVersion?.vulnerabilities?.length ? (
<div className="vuln-table">
<TableContainer
columnConfigs={tableHeaders}
data={softwareVersion.vulnerabilities}
defaultSortHeader={isPremiumTier ? "epss_probability" : "cve"}
defaultSortDirection={"desc"}
emptyComponent={NoVulnsDetected}
isAllPagesSelected={false}
isLoading={isSoftwareVersionLoading}
isClientSidePagination
pageSize={20}
resultsTitle={"vulnerabilities"}
showMarkAllPages={false}
/>
</div>
) : (
<NoVulnsDetected />
)}
<SoftwareVulnerabilitiesTable
data={softwareVersion.vulnerabilities ?? []}
itemName="software item"
isLoading={isSoftwareVersionLoading}
/>
</div>
</>
);
@@ -16,10 +16,4 @@
font-size: $medium;
}
}
// used to position header text with premium icon correctly
.column-header {
display: flex;
gap: $pad-small;
}
}
@@ -1,318 +0,0 @@
import React, { useCallback, useContext, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { Row } from "react-table";
import PATHS from "router/paths";
import softwareAPI, {
ISoftwareApiParams,
ISoftwareVersionsResponse,
} from "services/entities/software";
import { AppContext } from "context/app";
import {
GITHUB_NEW_ISSUE_LINK,
VULNERABLE_DROPDOWN_OPTIONS,
} from "utilities/constants";
import { getNextLocationPath } from "utilities/helpers";
import { buildQueryStringFromParams } from "utilities/url";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import TableDataError from "components/DataError";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import LastUpdatedText from "components/LastUpdatedText";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import EmptySoftwareTable from "../components/EmptySoftwareTable";
import generateSoftwareVersionsTableHeaders from "./SoftwareVersionsTableConfig";
const baseClass = "software-versions";
interface IRowProps extends Row {
original: {
id?: number;
};
}
interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
scope: "software-versions";
}
interface ISoftwareVersionsProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
query: string;
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
showVulnerableSoftware: boolean;
currentPage: number;
teamId?: number;
}
const SoftwareVersions = ({
router,
isSoftwareEnabled,
query,
perPage,
orderDirection,
orderKey,
showVulnerableSoftware,
currentPage,
teamId,
}: ISoftwareVersionsProps) => {
const { isSandboxMode, noSandboxHosts, isPremiumTier } = useContext(
AppContext
);
// request to get software versions data
const {
data: softwareVersionsData,
isLoading: isSoftwareVersionsLoading,
isError: isSoftwareVersionsError,
} = useQuery<
ISoftwareVersionsResponse,
Error,
ISoftwareVersionsResponse,
ISoftwareVersionsQueryKey[]
>(
[
{
scope: "software-versions",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
vulnerable: showVulnerableSoftware,
},
],
({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]),
{
keepPreviousData: true,
// stale time can be adjusted if fresher data is desired based on
// software inventory interval
staleTime: 30000,
}
);
// determines if a user be able to search in the table
const searchable =
isSoftwareEnabled &&
(!!softwareVersionsData?.software ||
query !== "" ||
showVulnerableSoftware);
const softwareTableHeaders = useMemo(
() =>
generateSoftwareVersionsTableHeaders(
router,
isPremiumTier,
isSandboxMode,
teamId
),
[isPremiumTier, isSandboxMode, router, teamId]
);
// TODO: figure out why this is not working
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
router.replace(
getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_VERSIONS,
routeTemplate: "",
queryParams: {
query,
teamId,
orderDirection,
orderKey,
vulnerable: isFilterVulnerable,
page: 0, // resets page index
},
})
);
};
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = {
software_version_id: row.original.id,
team_id: teamId,
};
const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
hostsBySoftwareParams
)}`;
router.push(path);
};
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
switch (key) {
case "searchQuery":
return val !== query;
case "sortDirection":
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "vulnerable":
return val !== showVulnerableSoftware.toString();
case "pageIndex":
return val !== currentPage;
default:
return false;
}
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey, query, showVulnerableSoftware]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData) => {
return {
query: newTableQuery.searchQuery,
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
vulnerable: showVulnerableSoftware.toString(),
page: newTableQuery.pageIndex,
};
},
[showVulnerableSoftware, teamId]
);
// NOTE: this is called once on initial render and every time the query changes
const onQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
// we want to determine which query param has changed in order to
// reset the page index to 0 if any other param has changed.
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_VERSIONS,
routeTemplate: "",
queryParams: generateNewQueryParams(newTableQuery),
});
router.replace(newRoute);
},
[determineQueryParamChange, generateNewQueryParams, router]
);
const getItemsCountText = () => {
const count = softwareVersionsData?.count;
if (!softwareVersionsData || !count) return "";
return count === 1 ? `${count} item` : `${count} items`;
};
const getLastUpdatedText = () => {
if (!softwareVersionsData || !softwareVersionsData.counts_updated_at)
return "";
return (
<LastUpdatedText
lastUpdatedAt={softwareVersionsData.counts_updated_at}
whatToRetrieve={"software"}
/>
);
};
const renderSoftwareCount = () => {
const itemText = getItemsCountText();
const lastUpdatedText = getLastUpdatedText();
if (!itemText) return null;
return (
<div className={`${baseClass}__count`}>
<span>{itemText}</span>
{lastUpdatedText}
</div>
);
};
const renderVulnFilterDropdown = () => {
return (
<Dropdown
value={showVulnerableSoftware}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
searchable={false}
onChange={handleVulnFilterDropdownChange}
tableFilterDropdown
/>
);
};
const renderTableFooter = () => {
return (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
};
if (isSoftwareVersionsError) {
return <TableDataError className={`${baseClass}__table-error`} />;
}
return (
<div className={baseClass}>
<div className={baseClass}>
<TableContainer
columnConfigs={softwareTableHeaders}
data={softwareVersionsData?.software || []}
isLoading={isSoftwareVersionsLoading}
resultsTitle={"items"}
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isFilterVulnerable={showVulnerableSoftware}
isSandboxMode={isSandboxMode}
isCollectingSoftware={false} // TODO: update with new API
isSearching={query !== ""}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}
defaultPageIndex={currentPage}
defaultSearchQuery={query}
manualSortBy
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!softwareVersionsData?.meta.has_next_results}
searchable={searchable}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
onQueryChange={onQueryChange}
// additionalQueries serves as a trigger for the useDeepEffect hook
// to fire onQueryChange for events happeing outside of
// the TableContainer.
additionalQueries={showVulnerableSoftware ? "vulnerable" : ""}
customControl={searchable ? renderVulnFilterDropdown : undefined}
stackControls
renderCount={renderSoftwareCount}
renderFooter={renderTableFooter}
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
/>
</div>
</div>
);
};
export default SoftwareVersions;
@@ -1 +0,0 @@
export { default } from "./SoftwareVersions";
@@ -26,6 +26,7 @@ import Slider from "components/forms/fields/Slider";
import Radio from "components/forms/fields/Radio";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import CustomLink from "components/CustomLink";
import validUrl from "components/forms/validators/valid_url";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
@@ -473,6 +474,15 @@ const ManageAutomationsModal = ({
/>
</div>
{integrationEnabled ? renderTicket() : renderWebhook()}
<p>
Vulnerability automations currently run for software
vulnerabilities. Interested in automations for OS vulnerabilities?{" "}
<CustomLink
url="https://www.fleetdm.com/support"
text="Let us know"
newTab
/>
</p>
</div>
<div className="modal-cta-wrap">
<div
@@ -1,5 +1,9 @@
import ViewAllHostsLink from "components/ViewAllHostsLink";
import React from "react";
import { QueryParams } from "utilities/url";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import SoftwareIcon from "../icons/SoftwareIcon";
const baseClass = "software-details-summary";
@@ -20,23 +24,21 @@ const DataSet = ({ title, value }: IDescriptionSetProps) => {
};
interface ISoftwareDetailsSummaryProps {
id: number;
title: string;
type: string;
type?: string;
hosts: number;
/** The query param name that will be added when user clicks on "View all hosts" link */
queryParam: string;
/** The query param that will be added when user clicks on "View all hosts" link */
queryParams: QueryParams;
name?: string;
source?: string;
versions?: number;
}
const SoftwareDetailsSummary = ({
id,
title,
type,
hosts,
queryParam,
queryParams,
name,
source,
versions,
@@ -47,18 +49,15 @@ const SoftwareDetailsSummary = ({
<dl className={`${baseClass}__info`}>
<h1>{title}</h1>
<dl className={`${baseClass}__description-list`}>
<DataSet
title="Type"
// value={formatSoftwareType(software.source)} TODO: format value
value={type}
/>
{type && <DataSet title="Type" value={type} />}
{versions && <DataSet title="Versions" value={versions} />}
<DataSet title="Hosts" value={hosts === 0 ? "---" : hosts} />
</dl>
</dl>
<div>
<ViewAllHostsLink
queryParams={{ [queryParam]: id }}
queryParams={queryParams}
className={`${baseClass}__hosts-link`}
/>
</div>
@@ -0,0 +1,79 @@
import React, { useContext, useMemo } from "react";
import classnames from "classnames";
import { AppContext } from "context/app";
import { ISoftwareVulnerability } from "interfaces/software";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import generateTableConfig from "./SoftwareVulnerabilitiesTableConfig";
const baseClass = "software-vulnerabilities-table";
interface INoVulnsDetectedProps {
itemName: string;
}
const NoVulnsDetected = ({ itemName }: INoVulnsDetectedProps): JSX.Element => {
return (
<EmptyTable
header={`No vulnerabilities detected for this ${itemName}`}
info={
<>
Expecting to see vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</>
}
/>
);
};
interface ISoftwareVulnerabilitiesTableProps {
data: ISoftwareVulnerability[];
/** Name displayed on the empty state */
itemName: string;
isLoading: boolean;
className?: string;
}
const SoftwareVulnerabilitiesTable = ({
data,
itemName,
isLoading,
className,
}: ISoftwareVulnerabilitiesTableProps) => {
const { isPremiumTier, isSandboxMode } = useContext(AppContext);
const classNames = classnames(baseClass, className);
const tableHeaders = useMemo(
() => generateTableConfig(Boolean(isPremiumTier), Boolean(isSandboxMode)),
[isPremiumTier, isSandboxMode]
);
return (
<div className={classNames}>
<TableContainer
columnConfigs={tableHeaders}
data={data}
defaultSortHeader={isPremiumTier ? "epss_probability" : "cve"}
defaultSortDirection={"desc"}
emptyComponent={() => <NoVulnsDetected itemName={itemName} />}
isAllPagesSelected={false}
isLoading={isLoading}
isClientSidePagination
pageSize={20}
resultsTitle={"vulnerabilities"}
showMarkAllPages={false}
/>
</div>
);
};
export default SoftwareVulnerabilitiesTable;
@@ -2,6 +2,7 @@ import React from "react";
import { formatFloatAsPercentage } from "utilities/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { ISoftwareVulnerability } from "interfaces/software";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
@@ -9,7 +10,6 @@ import TooltipWrapper from "components/TooltipWrapper";
import CustomLink from "components/CustomLink";
import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { ISoftwareVulnerability } from "interfaces/software";
interface IHeaderProps {
column: {
@@ -62,7 +62,7 @@ const formatSeverity = (float: number | null) => {
return `${severity} (${float.toFixed(1)})`;
};
const generateSoftwareVersionDetailsTableConfig = (
const generateTableConfig = (
isPremiumTier: boolean,
isSandboxMode: boolean
): IDataColumn[] => {
@@ -189,7 +189,6 @@ const generateSoftwareVersionDetailsTableConfig = (
title: "Published",
accessor: "cve_published",
disableSortBy: false,
sortType: "boolean",
Header: (headerProps: IHeaderProps): JSX.Element => {
const titleWithToolTip = (
<TooltipWrapper
@@ -228,4 +227,4 @@ const generateSoftwareVersionDetailsTableConfig = (
return isPremiumTier ? tableHeaders.concat(premiumHeaders) : tableHeaders;
};
export default generateSoftwareVersionDetailsTableConfig;
export default generateTableConfig;
@@ -0,0 +1,15 @@
.software-vulnerabilities-table {
// keeps table data within the table container at smaller screen sizes
.data-table {
&__wrapper {
overflow-x: auto;
}
}
// used to position header text with premium icon correctly
.column-header {
display: flex;
gap: $pad-small;
}
}
@@ -0,0 +1 @@
export { default } from "./SoftwareVulnerabilitiesTable";
@@ -9,6 +9,9 @@ import ReactTooltip from "react-tooltip";
const baseClass = "version-cell";
const generateText = (versions: ISoftwareTitleVersion[]) => {
if (!versions) {
return <TextCell value="Unavailable" greyed />;
}
const text =
versions.length !== 1 ? `${versions.length} versions` : versions[0].version;
return <TextCell value={text} greyed={versions.length !== 1} />;
@@ -18,7 +21,7 @@ const generateTooltip = (
versions: ISoftwareTitleVersion[],
tooltipId: string
) => {
if (versions.length <= 1) {
if (!versions) {
return null;
}
@@ -45,7 +48,7 @@ const VersionCell = ({ versions }: IVersionCellProps) => {
// only one version, no need for tooltip
const cellText = generateText(versions);
if (versions.length <= 1) {
if (!versions) {
return <>{cellText}</>;
}
@@ -162,9 +162,7 @@ const HostsFilterBlock = ({
if (!osId && !(osName && osVersion)) return null;
let os: IOperatingSystemVersion | undefined;
if (osId) {
os = osVersions?.find((v) => v.os_id === osId);
} else if (osName && osVersion) {
if (osName && osVersion) {
const name: string = osName;
const vers: string = osVersion;
+5 -2
View File
@@ -60,9 +60,10 @@ import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/A
import HostQueryReport from "pages/hosts/details/HostQueryReport";
import SoftwarePage from "pages/SoftwarePage";
import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles";
import SoftwareVersions from "pages/SoftwarePage/SoftwareVersions";
import SoftwareOS from "pages/SoftwarePage/SoftwareOS";
import SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPage";
import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage";
import SoftwareOSDetailsPage from "pages/SoftwarePage/SoftwareOSDetailsPage";
import PATHS from "router/paths";
@@ -217,12 +218,14 @@ const routes = (
<IndexRedirect to="titles" />
<Route component={SoftwarePage}>
<Route path="titles" component={SoftwareTitles} />
<Route path="versions" component={SoftwareVersions} />
<Route path="versions" component={SoftwareTitles} />
<Route path="os" component={SoftwareOS} />
{/* This redirect keeps the old software/:id working */}
<Redirect from=":id" to="versions/:id" />
</Route>
<Route path="titles/:id" component={SoftwareTitleDetailsPage} />
<Route path="versions/:id" component={SoftwareVersionDetailsPage} />
<Route path="os/details" component={SoftwareOSDetailsPage} />
</Route>
<Route component={AuthGlobalAdminMaintainerRoutes}>
<Route path="packs">
+4
View File
@@ -47,6 +47,7 @@ export default {
// Software pages
SOFTWARE: `${URL_PREFIX}/software`,
SOFTWARE_TITLES: `${URL_PREFIX}/software/titles`,
SOFTWARE_OS: `${URL_PREFIX}/software/os`,
SOFTWARE_VERSIONS: `${URL_PREFIX}/software/versions`,
SOFTWARE_TITLE_DETAILS: (id: string): string => {
return `${URL_PREFIX}/software/titles/${id}`;
@@ -54,6 +55,9 @@ export default {
SOFTWARE_VERSION_DETAILS: (id: string): string => {
return `${URL_PREFIX}/software/versions/${id}`;
},
SOFTWARE_OS_DETAILS: (name: string, version: string): string => {
return `${URL_PREFIX}/software/os/details?name=${name}&version=${version}`;
},
EDIT_PACK: (packId: number): string => {
return `${URL_PREFIX}/packs/${packId}/edit`;
@@ -12,37 +12,82 @@ export const OS_VERSIONS_API_SUPPORTED_PLATFORMS = [
"chrome",
];
export interface IGetOSVersionsRequest {
id?: number;
export interface IGetOSVersionsQueryParams {
platform?: OsqueryPlatform;
teamId?: number;
os_name?: string;
os_version?: string;
order_key?: string;
order_direction?: string;
page?: number;
per_page?: number;
}
export interface IGetOSVersionsQueryKey extends IGetOSVersionsRequest {
export interface IGetOSVersionsDetailsQueryParams {
os_name?: string;
os_version?: string;
}
export interface IGetOSVersionsQueryKey extends IGetOSVersionsQueryParams {
scope: string;
}
export interface IOSVersionsResponse {
count: number;
counts_updated_at: string;
os_versions: IOperatingSystemVersion[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export const getOSVersions = ({
id,
platform,
teamId,
}: IGetOSVersionsRequest = {}): Promise<IOSVersionsResponse> => {
os_name,
os_version,
order_key,
order_direction,
page,
per_page,
}: IGetOSVersionsQueryParams = {}): Promise<IOSVersionsResponse> => {
const { OS_VERSIONS } = endpoints;
let path = OS_VERSIONS;
const queryParams = { id, platform, team_id: teamId };
const queryString = buildQueryStringFromParams(queryParams);
const queryString = buildQueryStringFromParams({
platform,
team_id: teamId,
os_name,
os_version,
order_key,
order_direction,
page,
per_page,
});
if (queryString) path += `?${queryString}`;
return sendRequest("GET", path);
};
const getOSVersion = ({
os_name,
os_version,
}: IGetOSVersionsDetailsQueryParams): Promise<IOSVersionsResponse> => {
const { OS_VERSIONS } = endpoints;
let path = OS_VERSIONS;
const queryString = buildQueryStringFromParams({
os_name,
os_version,
});
if (queryString) path += `?${queryString}`;
return sendRequest("GET", path);
};
export default {
getOSVersions,
getOSVersion,
};
+1
View File
@@ -39,6 +39,7 @@ export const FREQUENCY_DROPDOWN_OPTIONS = [
export const GITHUB_NEW_ISSUE_LINK =
"https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md";
export const SUPPORT_LINK = "https://fleetdm.com/support";
/** July 28, 2016 is the date of the initial commit to fleet/fleet. */
export const INITIAL_FLEET_DATE = "2016-07-28T00:00:00Z";
+4
View File
@@ -81,7 +81,11 @@ export default {
`/${API_VERSION}/fleet/mdm/hosts/${id}/encryption_key`,
ME: `/${API_VERSION}/fleet/me`,
// OS Version endpoints
OS_VERSIONS: `/${API_VERSION}/fleet/os_versions`,
OS_VERSION: (id: number) => `/${API_VERSION}/fleet/os_versions/${id}`,
OSQUERY_OPTIONS: `/${API_VERSION}/fleet/spec/osquery_options`,
PACKS: `/${API_VERSION}/fleet/packs`,
PERFORM_REQUIRED_PASSWORD_RESET: `/${API_VERSION}/fleet/perform_required_password_reset`,