= {
pkg_packages: "Package (pkg)",
} as const;
-export const formatSoftwareType = (source: string): string => {
+// TODO: update with new software types
+export const formatSoftwareType = (source: string) => {
const DICT = TYPE_CONVERSION;
return DICT[source] || "Unknown";
};
diff --git a/frontend/interfaces/vulnerability.ts b/frontend/interfaces/vulnerability.ts
index 168a3dda24..3806f61326 100644
--- a/frontend/interfaces/vulnerability.ts
+++ b/frontend/interfaces/vulnerability.ts
@@ -4,19 +4,3 @@ export default PropTypes.shape({
cve: PropTypes.string,
details_link: PropTypes.string,
});
-
-export interface IHostsAffected {
- id: number;
- display_name: string;
- url: string;
- software_installed_paths?: string[];
-}
-export interface IVulnerability {
- cve: string;
- details_link: string;
- cvss_score?: number;
- epss_probability?: number;
- cisa_known_exploit?: boolean;
- cve_published?: string;
- hosts_affected?: IHostsAffected[];
-}
diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx
index d4e0d4b228..3a224d8b63 100644
--- a/frontend/pages/DashboardPage/DashboardPage.tsx
+++ b/frontend/pages/DashboardPage/DashboardPage.tsx
@@ -465,11 +465,11 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
};
const onSoftwareTabChange = (index: number) => {
- const { MANAGE_SOFTWARE } = paths;
+ const { SOFTWARE_TITLES } = paths;
setSoftwareNavTabIndex(index);
setSoftwareActionUrl &&
setSoftwareActionUrl(
- index === 1 ? `${MANAGE_SOFTWARE}?vulnerable=true` : MANAGE_SOFTWARE
+ index === 1 ? `${SOFTWARE_TITLES}?vulnerable=true` : SOFTWARE_TITLES
);
};
diff --git a/frontend/pages/DashboardPage/cards/Software/Software.tsx b/frontend/pages/DashboardPage/cards/Software/Software.tsx
index 7696b72377..3377cd0b1b 100644
--- a/frontend/pages/DashboardPage/cards/Software/Software.tsx
+++ b/frontend/pages/DashboardPage/cards/Software/Software.tsx
@@ -11,7 +11,7 @@ import TabsWrapper from "components/TabsWrapper";
import TableContainer from "components/TableContainer";
import TableDataError from "components/DataError";
import Spinner from "components/Spinner";
-import EmptySoftwareTable from "pages/software/components/EmptySoftwareTable";
+import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
import generateTableHeaders from "./SoftwareTableConfig";
diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx
new file mode 100644
index 0000000000..1113a58c45
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx
@@ -0,0 +1,380 @@
+import React, { useCallback, useContext, useState } from "react";
+import { InjectedRouter } from "react-router";
+import { useQuery } from "react-query";
+import { Tab, TabList, Tabs } from "react-tabs";
+
+import PATHS from "router/paths";
+import {
+ IConfig,
+ CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS,
+} from "interfaces/config";
+import {
+ IJiraIntegration,
+ IZendeskIntegration,
+ IIntegrations,
+} from "interfaces/integration";
+import { ITeamConfig } from "interfaces/team";
+import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
+import configAPI from "services/entities/config";
+import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
+import { AppContext } from "context/app";
+import { NotificationContext } from "context/notification";
+import useTeamIdParam from "hooks/useTeamIdParam";
+
+import Button from "components/buttons/Button";
+import MainContent from "components/MainContent";
+import TeamsDropdown from "components/TeamsDropdown";
+import TabsWrapper from "components/TabsWrapper";
+
+import ManageAutomationsModal from "./components/ManageAutomationsModal";
+
+interface ISoftwareSubNavItem {
+ name: string;
+ pathname: string;
+}
+
+const softwareSubNav: ISoftwareSubNavItem[] = [
+ {
+ name: "Software",
+ pathname: PATHS.SOFTWARE_TITLES,
+ },
+ {
+ name: "Versions",
+ pathname: PATHS.SOFTWARE_VERSIONS,
+ },
+];
+
+const getTabIndex = (path: string): number => {
+ return softwareSubNav.findIndex((navItem) => {
+ // tab stays highlighted for paths that start with same pathname
+ return path.startsWith(navItem.pathname);
+ });
+};
+
+// default values for query params used on this page if not provided
+const DEFAULT_SORT_DIRECTION = "desc";
+const DEFAULT_SORT_HEADER = "hosts_count";
+const DEFAULT_PAGE_SIZE = 20;
+const DEFAULT_PAGE = 0;
+
+const baseClass = "software-page";
+
+interface ISoftwareAutomations {
+ webhook_settings: {
+ vulnerabilities_webhook: IWebhookSoftwareVulnerabilities;
+ };
+ integrations: {
+ jira: IJiraIntegration[];
+ zendesk: IZendeskIntegration[];
+ };
+}
+
+interface ISoftwareConfigQueryKey {
+ scope: string;
+ teamId?: number;
+}
+
+interface ISoftwarePageProps {
+ children: JSX.Element;
+ location: {
+ pathname: string;
+ search: string;
+ query: {
+ team_id?: string;
+ vulnerable?: string;
+ page?: string;
+ query?: string;
+ order_key?: string;
+ order_direction?: "asc" | "desc";
+ };
+ hash?: string;
+ };
+ router: InjectedRouter; // v3
+}
+
+const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
+ const {
+ config: globalConfig,
+ isFreeTier,
+ isGlobalAdmin,
+ isGlobalMaintainer,
+ isOnGlobalTeam,
+ isPremiumTier,
+ isSandboxMode,
+ } = useContext(AppContext);
+ const { renderFlash } = useContext(NotificationContext);
+
+ 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
+ : DEFAULT_SORT_HEADER;
+ const sortDirection =
+ queryParams?.order_direction === undefined
+ ? DEFAULT_SORT_DIRECTION
+ : queryParams.order_direction;
+ const page =
+ queryParams && queryParams.page
+ ? parseInt(queryParams.page, 10)
+ : DEFAULT_PAGE;
+ const showVulnerableSoftware =
+ queryParams !== undefined && queryParams.vulnerable === "true";
+
+ const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
+ false
+ );
+ const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
+ const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
+
+ const {
+ currentTeamId,
+ isAnyTeamSelected,
+ isRouteOk,
+ teamIdForApi,
+ userTeams,
+ handleTeamChange,
+ } = useTeamIdParam({
+ location,
+ router,
+ includeAllTeams: true,
+ includeNoTeam: false,
+ });
+
+ // softwareConfig is either the global config or the team config of the
+ // currently selected team depending on the page team context selected
+ // by the user.
+ const {
+ data: softwareConfig,
+ error: softwareConfigError,
+ isFetching: isFetchingSoftwareConfig,
+ refetch: refetchSoftwareConfig,
+ } = useQuery<
+ IConfig | ILoadTeamResponse,
+ Error,
+ IConfig | ITeamConfig,
+ ISoftwareConfigQueryKey[]
+ >(
+ [{ scope: "softwareConfig", teamId: teamIdForApi }],
+ ({ queryKey }) => {
+ const { teamId } = queryKey[0];
+ return teamId ? teamsAPI.load(teamId) : configAPI.loadAll();
+ },
+ {
+ enabled: isRouteOk,
+ select: (data) => ("team" in data ? data.team : data),
+ }
+ );
+
+ // TODO: move into manage automations modal
+ const vulnWebhookSettings =
+ softwareConfig?.webhook_settings?.vulnerabilities_webhook;
+ const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook;
+ const isVulnIntegrationEnabled = (integrations?: IIntegrations) => {
+ return (
+ !!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) ||
+ !!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities)
+ );
+ };
+
+ // TODO: move into manage automations modal
+ const isAnyVulnAutomationEnabled =
+ isVulnWebhookEnabled ||
+ isVulnIntegrationEnabled(softwareConfig?.integrations);
+
+ // TODO: move into manage automations modal
+ const recentVulnerabilityMaxAge = (() => {
+ let maxAgeInNanoseconds: number | undefined;
+ if (softwareConfig && "vulnerabilities" in softwareConfig) {
+ maxAgeInNanoseconds =
+ softwareConfig.vulnerabilities.recent_vulnerability_max_age;
+ } else {
+ maxAgeInNanoseconds =
+ globalConfig?.vulnerabilities.recent_vulnerability_max_age;
+ }
+ return maxAgeInNanoseconds
+ ? Math.round(maxAgeInNanoseconds / 86400000000000) // convert from nanoseconds to days
+ : CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS;
+ })();
+
+ const isSoftwareConfigLoaded =
+ !isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
+
+ const canManageAutomations =
+ isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
+
+ const toggleManageAutomationsModal = useCallback(() => {
+ setShowManageAutomationsModal(!showManageAutomationsModal);
+ }, [setShowManageAutomationsModal, showManageAutomationsModal]);
+
+ const togglePreviewPayloadModal = useCallback(() => {
+ setShowPreviewPayloadModal(!showPreviewPayloadModal);
+ }, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
+
+ const togglePreviewTicketModal = useCallback(() => {
+ setShowPreviewTicketModal(!showPreviewTicketModal);
+ }, [setShowPreviewTicketModal, showPreviewTicketModal]);
+
+ // TODO: move into manage automations modal
+ const onCreateWebhookSubmit = async (
+ configSoftwareAutomations: ISoftwareAutomations
+ ) => {
+ try {
+ const request = configAPI.update(configSoftwareAutomations);
+ await request.then(() => {
+ renderFlash(
+ "success",
+ "Successfully updated vulnerability automations."
+ );
+ refetchSoftwareConfig();
+ });
+ } catch {
+ renderFlash(
+ "error",
+ "Could not update vulnerability automations. Please try again."
+ );
+ } finally {
+ toggleManageAutomationsModal();
+ }
+ };
+
+ const onTeamChange = useCallback(
+ (teamId: number) => {
+ handleTeamChange(teamId);
+ // TODO: reset page to 0 when changing teams
+ },
+ [handleTeamChange]
+ );
+
+ const navigateToNav = useCallback(
+ (i: number): void => {
+ const navPath = softwareSubNav[i].pathname;
+ router.replace(
+ navPath.concat(location?.search || "").concat(location?.hash || "")
+ );
+ },
+ [location, router]
+ );
+
+ const renderTitle = () => {
+ return (
+ <>
+ {isFreeTier && Software }
+ {isPremiumTier &&
+ userTeams &&
+ (userTeams.length > 1 || isOnGlobalTeam) && (
+
+ )}
+ {isPremiumTier &&
+ !isOnGlobalTeam &&
+ userTeams &&
+ userTeams.length === 1 && {userTeams[0].name} }
+ >
+ );
+ };
+
+ const renderHeaderDescription = () => {
+ return (
+
+ Search for installed software{" "}
+ {(isGlobalAdmin || isGlobalMaintainer) &&
+ (!isPremiumTier || !isAnyTeamSelected) &&
+ "and manage automations for detected vulnerabilities (CVEs)"}{" "}
+ on{" "}
+
+ {isPremiumTier && isAnyTeamSelected
+ ? "all hosts assigned to this team"
+ : "all of your hosts"}
+
+ .
+
+ );
+ };
+
+ const renderBody = () => {
+ return (
+
+
+
+
+ {softwareSubNav.map((navItem) => {
+ return (
+
+ {navItem.name}
+
+ );
+ })}
+
+
+
+ {React.cloneElement(children, {
+ router,
+ 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,
+ })}
+
+ );
+ };
+
+ return (
+
+
+
+
+ {canManageAutomations && isSoftwareConfigLoaded && (
+
+ Manage automations
+
+ )}
+
+
+ {renderHeaderDescription()}
+
+ {renderBody()}
+ {showManageAutomationsModal && (
+
+ )}
+
+
+ );
+};
+
+export default SoftwarePage;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
new file mode 100644
index 0000000000..26eaffecfa
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
@@ -0,0 +1,82 @@
+import React, { useContext } from "react";
+import { RouteComponentProps } from "react-router";
+import { useQuery } from "react-query";
+
+import { AppContext } from "context/app";
+import { ISoftwareTitle } from "interfaces/software";
+import softwareAPI, {
+ ISoftwareTitleResponse,
+} from "services/entities/software";
+
+import MainContent from "components/MainContent";
+import TableDataError from "components/DataError";
+
+import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
+import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
+
+const baseClass = "software-title-details-page";
+
+interface ISoftwareTitleDetailsRouteParams {
+ id: string;
+}
+
+type ISoftwareTitleDetailsPageProps = RouteComponentProps<
+ undefined,
+ ISoftwareTitleDetailsRouteParams
+>;
+
+const SoftwareTitleDetailsPage = ({
+ router,
+ routeParams,
+}: ISoftwareTitleDetailsPageProps) => {
+ // TODO: handle non integer values
+ const softwareId = parseInt(routeParams.id, 10);
+
+ const {
+ data: softwareTitle,
+ isLoading: isSoftwareTitleLoading,
+ isError: isSoftwareTitleError,
+ } = useQuery(
+ ["softwareById", softwareId],
+ () => softwareAPI.getSoftwareTitle(softwareId),
+ {
+ select: (data) => data.software_title,
+ }
+ );
+
+ if (!softwareTitle) {
+ return null;
+ }
+
+ return (
+
+ {isSoftwareTitleError ? (
+
+ ) : (
+ <>
+
+ {/* TODO: can we use Card here for card styles */}
+
+
Versions
+
+
+ >
+ )}
+
+ );
+};
+
+export default SoftwareTitleDetailsPage;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx
new file mode 100644
index 0000000000..e42d6decfc
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx
@@ -0,0 +1,70 @@
+import React, { useMemo } from "react";
+import { InjectedRouter } from "react-router";
+
+import { ISoftwareTitleVersion } 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 generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig";
+
+const DEFAULT_SORT_HEADER = "hosts_count";
+const DEFAULT_SORT_DIRECTION = "desc";
+
+const baseClass = "software-title-details-table";
+
+const NoVersionsDetected = (): JSX.Element => {
+ return (
+
+ Expecting to see versions?{" "}
+
+ >
+ }
+ />
+ );
+};
+
+interface ISoftwareTitleDetailsTableProps {
+ router: InjectedRouter;
+ data: ISoftwareTitleVersion[];
+ isLoading: boolean;
+}
+
+const SoftwareTitleDetailsTable = ({
+ router,
+ data,
+ isLoading,
+}: ISoftwareTitleDetailsTableProps) => {
+ const softwareTableHeaders = useMemo(
+ () => generateSoftwareTitleDetailsTableConfig(router),
+ [router]
+ );
+
+ return (
+
+ );
+};
+
+export default SoftwareTitleDetailsTable;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTableConfig.tsx
new file mode 100644
index 0000000000..6e3e9c80bf
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTableConfig.tsx
@@ -0,0 +1,111 @@
+import React from "react";
+import { InjectedRouter } from "react-router";
+
+import {
+ ISoftwareTitleVersion,
+ ISoftwareVulnerability,
+} from "interfaces/software";
+import PATHS from "router/paths";
+
+import TextCell from "components/TableContainer/DataTable/TextCell";
+import ViewAllHostsLink from "components/ViewAllHostsLink";
+import LinkCell from "components/TableContainer/DataTable/LinkCell";
+
+import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
+
+interface ICellProps {
+ cell: {
+ value: number | string | ISoftwareVulnerability[];
+ };
+ row: {
+ original: ISoftwareTitleVersion;
+ };
+}
+
+interface IVersionCellProps extends ICellProps {
+ cell: {
+ value: string;
+ };
+}
+
+interface INumberCellProps extends ICellProps {
+ cell: {
+ value: number;
+ };
+}
+
+interface IVulnCellProps extends ICellProps {
+ cell: {
+ value: ISoftwareVulnerability[];
+ };
+}
+
+const generateSoftwareTitleDetailsTableConfig = (router: InjectedRouter) => {
+ const tableHeaders = [
+ {
+ title: "Version",
+ Header: "Version",
+ disableSortBy: true,
+ accessor: "version",
+ Cell: (cellProps: IVersionCellProps): JSX.Element => {
+ const { id } = cellProps.row.original;
+ const onClickSoftware = (e: React.MouseEvent) => {
+ // Allows for button to be clickable in a clickable row
+ e.stopPropagation();
+ router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString()));
+ };
+
+ // TODO: make only text clickable
+ return (
+
+ );
+ },
+ },
+ {
+ title: "Vulnerabilities",
+ Header: "Vulnerabilities",
+ disableSortBy: true,
+ // the "vulnerabilities" accessor is used but the data is actually coming
+ // from the version attribute. We do this as we already have a "versions"
+ // attribute used for the "Version" column and we cannot reuse. This is a
+ // limitation of react-table.
+ // With the versions data, we can sum up the vulnerabilities to get the
+ // total number of vulnerabilities for the software title
+ accessor: "vulnerabilities",
+ Cell: (cellProps: IVulnCellProps): JSX.Element => (
+
+ // TODO: tooltip
+ ),
+ },
+ {
+ title: "Hosts",
+ Header: "Hosts",
+ disableSortBy: true,
+ accessor: "hosts_count",
+ Cell: (cellProps: INumberCellProps): JSX.Element => (
+
+
+
+
+
+
+
+
+ ),
+ },
+ ];
+
+ return tableHeaders;
+};
+
+export default generateSoftwareTitleDetailsTableConfig;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/_styles.scss
new file mode 100644
index 0000000000..4226d6790f
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/_styles.scss
@@ -0,0 +1,13 @@
+.software-title-details-table {
+
+ .hosts-cell__wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ .hosts-cell__link {
+ display: flex;
+ white-space: nowrap;
+ }
+ }
+}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/index.ts
new file mode 100644
index 0000000000..2e2c71c6e0
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwareTitleDetailsTable";
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss
new file mode 100644
index 0000000000..4a0f501b35
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss
@@ -0,0 +1,33 @@
+.software-title-details-page {
+ background-color: $ui-off-white;
+ display: flex;
+ flex-direction: column;
+ gap: $pad-medium;
+
+ &__versions-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;
+ }
+ }
+
+ // for showing and hiding software link on hover
+ tr {
+ .software-link {
+ opacity: 0;
+ transition: opacity 250ms;
+ }
+
+ &:hover {
+ .software-link {
+ opacity: 1;
+ }
+ }
+ }
+}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/index.ts
new file mode 100644
index 0000000000..a071356328
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwareTitleDetailsPage";
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx
new file mode 100644
index 0000000000..df84248aa7
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx
@@ -0,0 +1,303 @@
+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,
+ ISoftwareTitlesResponse,
+} 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 generateSoftwareTitlesTableHeaders from "./SoftwareTitlesTableConfig";
+
+const baseClass = "software-titles";
+
+interface IRowProps extends Row {
+ original: {
+ id?: number;
+ };
+}
+
+interface ISoftwareTitlesQueryKey extends ISoftwareApiParams {
+ scope: "software-titles";
+}
+
+interface ISoftwareTitlesProps {
+ router: InjectedRouter;
+ isSoftwareEnabled: boolean;
+ query: string;
+ perPage: number;
+ orderDirection: "asc" | "desc";
+ orderKey: string;
+ showVulnerableSoftware: boolean;
+ currentPage: number;
+ teamId?: number;
+}
+
+const SoftwareTitles = ({
+ router,
+ isSoftwareEnabled,
+ query,
+ perPage,
+ orderDirection,
+ orderKey,
+ showVulnerableSoftware,
+ currentPage,
+ teamId,
+}: ISoftwareTitlesProps) => {
+ const { isSandboxMode, noSandboxHosts } = useContext(AppContext);
+
+ // request to get software data
+ const {
+ data: softwareData,
+ isLoading: isSoftwareLoading,
+ isError: isSoftwareError,
+ } = useQuery<
+ ISoftwareTitlesResponse,
+ Error,
+ ISoftwareTitlesResponse,
+ ISoftwareTitlesQueryKey[]
+ >(
+ [
+ {
+ scope: "software-titles",
+ page: currentPage,
+ perPage,
+ query,
+ orderDirection,
+ orderKey,
+ teamId,
+ vulnerable: showVulnerableSoftware,
+ },
+ ],
+ ({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]),
+ {
+ // 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 &&
+ (!!softwareData?.software_titles || query !== "" || showVulnerableSoftware);
+
+ const softwareTableHeaders = useMemo(
+ () => generateSoftwareTitlesTableHeaders(router, teamId),
+ [router, teamId]
+ );
+
+ const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
+ router.replace(
+ getNextLocationPath({
+ pathPrefix: PATHS.SOFTWARE_TITLES,
+ routeTemplate: "",
+ queryParams: {
+ query,
+ teamId,
+ orderDirection,
+ orderKey,
+ vulnerable: isFilterVulnerable,
+ page: 0, // resets page index
+ },
+ })
+ );
+ };
+
+ 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 (
+
+ );
+ };
+
+ const renderSoftwareCount = () => {
+ const itemText = getItemsCountText();
+ const lastUpdatedText = getLastUpdatedText();
+
+ if (!itemText) return null;
+
+ return (
+
+ {itemText}
+ {lastUpdatedText}
+
+ );
+ };
+
+ const renderVulnFilterDropdown = () => {
+ return (
+
+ );
+ };
+
+ const renderTableFooter = () => {
+ return (
+
+ Seeing unexpected software or vulnerabilities?{" "}
+
+
+ );
+ };
+
+ if (isSoftwareError) {
+ return ;
+ }
+
+ return (
+
+
(
+
+ )}
+ 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}
+ />
+
+ );
+};
+
+export default SoftwareTitles;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitlesTableConfig.tsx
new file mode 100644
index 0000000000..3e48b76a55
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitlesTableConfig.tsx
@@ -0,0 +1,185 @@
+import React from "react";
+import { Column } from "react-table";
+import { InjectedRouter } from "react-router";
+
+import {
+ ISoftwareTitleVersion,
+ ISoftwareTitle,
+ formatSoftwareType,
+} from "interfaces/software";
+import PATHS from "router/paths";
+
+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";
+
+// NOTE: cellProps come from react-table
+// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
+interface ICellProps {
+ cell: {
+ value: number | string | ISoftwareTitleVersion[];
+ };
+ row: {
+ original: ISoftwareTitle;
+ };
+}
+interface IStringCellProps extends ICellProps {
+ cell: {
+ value: string;
+ };
+}
+
+interface IVersionCellProps extends ICellProps {
+ cell: {
+ value: ISoftwareTitleVersion[];
+ };
+ row: {
+ original: ISoftwareTitle;
+ };
+}
+
+interface INumberCellProps extends ICellProps {
+ cell: {
+ value: number;
+ };
+}
+
+interface IVulnCellProps extends ICellProps {
+ cell: {
+ value: ISoftwareTitleVersion[];
+ };
+}
+interface IHeaderProps {
+ column: {
+ title: string;
+ isSortedDesc: boolean;
+ };
+}
+
+const getVulnerabilities = (versions: ISoftwareTitleVersion[]) => {
+ const vulnerabilities = versions.reduce((acc: string[], currentVersion) => {
+ if (
+ currentVersion.vulnerabilities &&
+ currentVersion.vulnerabilities.length !== 0
+ ) {
+ acc.push(...currentVersion.vulnerabilities);
+ }
+ return acc;
+ }, []);
+ return vulnerabilities;
+};
+
+const generateTableHeaders = (
+ router: InjectedRouter,
+ teamId?: number
+): Column[] => {
+ const softwareTableHeaders = [
+ {
+ title: "Name",
+ Header: (cellProps: IHeaderProps): JSX.Element => (
+
+ ),
+ disableSortBy: false,
+ accessor: "name",
+ Cell: (cellProps: IStringCellProps): JSX.Element => {
+ const { id, name, source } = cellProps.row.original;
+
+ const onClickSoftware = (e: React.MouseEvent) => {
+ // Allows for button to be clickable in a clickable row
+ e.stopPropagation();
+
+ router?.push(PATHS.SOFTWARE_TITLE_DETAILS(id.toString()));
+ };
+
+ return (
+
+
+ {name}
+ >
+ }
+ />
+ );
+ },
+ sortType: "caseInsensitive",
+ },
+ {
+ title: "Version",
+ Header: "Version",
+ disableSortBy: true,
+ accessor: "versions",
+ Cell: (cellProps: IVersionCellProps): JSX.Element => (
+
+ ),
+ },
+ {
+ title: "Type",
+ Header: "Type",
+ disableSortBy: true,
+ accessor: "source",
+ Cell: (cellProps: IStringCellProps): JSX.Element => (
+
+ ),
+ },
+ // the "vulnerabilities" accessor is used but the data is actually coming
+ // from the version attribute. We do this as we already have a "versions"
+ // attribute used for the "Version" column and we cannot reuse. This is a
+ // limitation of react-table.
+ // With the versions data, we can sum up the vulnerabilities to get the
+ // total number of vulnerabilities for the software title
+ {
+ title: "Vulnerabilities",
+ Header: "Vulnerabilities",
+ disableSortBy: true,
+ accessor: "vulnerabilities",
+ Cell: (cellProps: IVulnCellProps): JSX.Element => {
+ const vulnerabilities = getVulnerabilities(
+ cellProps.row.original.versions
+ );
+ return ;
+ },
+ },
+ {
+ title: "Hosts",
+ Header: (cellProps: IHeaderProps): JSX.Element => (
+
+ ),
+ disableSortBy: false,
+ accessor: "hosts_count",
+ Cell: (cellProps: INumberCellProps): JSX.Element => (
+
+
+
+
+
+
+
+
+ ),
+ },
+ ];
+
+ return softwareTableHeaders;
+};
+
+export default generateTableHeaders;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss
new file mode 100644
index 0000000000..ee2569f458
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss
@@ -0,0 +1,165 @@
+.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;
+ }
+}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/index.ts b/frontend/pages/SoftwarePage/SoftwareTitles/index.ts
new file mode 100644
index 0000000000..36fd17a897
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwareTitles";
diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx
new file mode 100644
index 0000000000..e8860641e3
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx
@@ -0,0 +1,125 @@
+import React, { useContext, useMemo } from "react";
+import { useQuery } from "react-query";
+import { RouteComponentProps } from "react-router";
+
+import softwareAPI, {
+ ISoftwareVersionResponse,
+} from "services/entities/software";
+import { ISoftwareVersion } 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 generateSoftwareVersionDetailsTableConfig from "./SoftwareVersionDetailsTableConfig";
+import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
+
+const baseClass = "software-version-details-page";
+
+interface ISoftwareVersionDetailsRouteParams {
+ id: string;
+}
+
+type ISoftwareTitleDetailsPageProps = RouteComponentProps<
+ undefined,
+ ISoftwareVersionDetailsRouteParams
+>;
+
+const NoVulnsDetected = (): JSX.Element => {
+ return (
+
+ Expecting to see vulnerabilities?{" "}
+
+ >
+ }
+ />
+ );
+};
+
+const SoftwareVersionDetailsPage = ({
+ routeParams,
+}: ISoftwareTitleDetailsPageProps) => {
+ const versionId = parseInt(routeParams.id, 10);
+ const { isPremiumTier, isSandboxMode, filteredSoftwarePath } = useContext(
+ AppContext
+ );
+
+ const {
+ data: softwareVersion,
+ isLoading: isSoftwareVersionLoading,
+ isError: isSoftwareVersionError,
+ } = useQuery(
+ ["software-version", versionId],
+ () => softwareAPI.getSoftwareVersion(versionId),
+ {
+ select: (data) => data.software,
+ }
+ );
+
+ const tableHeaders = useMemo(
+ () =>
+ generateSoftwareVersionDetailsTableConfig(
+ Boolean(isPremiumTier),
+ Boolean(isSandboxMode)
+ ),
+ [isPremiumTier, isSandboxMode]
+ );
+
+ if (!softwareVersion) {
+ return null;
+ }
+
+ return (
+
+ {isSoftwareVersionError ? (
+
+ ) : (
+ <>
+
+
+
Vulnerabilities
+ {softwareVersion?.vulnerabilities?.length ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+ );
+};
+export default SoftwareVersionDetailsPage;
diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/VulnTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsTableConfig.tsx
similarity index 96%
rename from frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/VulnTableConfig.tsx
rename to frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsTableConfig.tsx
index 518b8f58b5..1d3b036694 100644
--- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/VulnTableConfig.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsTableConfig.tsx
@@ -1,6 +1,5 @@
import React from "react";
-import { IVulnerability } from "interfaces/vulnerability";
import { formatFloatAsPercentage } from "utilities/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
@@ -10,6 +9,7 @@ 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: {
@@ -22,7 +22,7 @@ interface ICellProps {
value: number | string | string[];
};
row: {
- original: IVulnerability;
+ original: ISoftwareVulnerability;
index: number;
};
}
@@ -62,7 +62,7 @@ const formatSeverity = (float: number | null) => {
return `${severity} (${float.toFixed(1)})`;
};
-const generateVulnTableHeaders = (
+const generateSoftwareVersionDetailsTableConfig = (
isPremiumTier: boolean,
isSandboxMode: boolean
): IDataColumn[] => {
@@ -106,11 +106,11 @@ const generateVulnTableHeaders = (
);
return (
<>
- {isSandboxMode && }
+ {isSandboxMode && }
>
);
},
@@ -140,11 +140,11 @@ const generateVulnTableHeaders = (
);
return (
<>
- {isSandboxMode && }
+ {isSandboxMode && }
>
);
},
@@ -173,11 +173,11 @@ const generateVulnTableHeaders = (
);
return (
<>
- {isSandboxMode && }
+ {isSandboxMode && }
>
);
},
@@ -205,11 +205,11 @@ const generateVulnTableHeaders = (
);
return (
<>
- {isSandboxMode && }
+ {isSandboxMode && }
>
);
},
@@ -228,4 +228,4 @@ const generateVulnTableHeaders = (
return isPremiumTier ? tableHeaders.concat(premiumHeaders) : tableHeaders;
};
-export default generateVulnTableHeaders;
+export default generateSoftwareVersionDetailsTableConfig;
diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/_styles.scss
new file mode 100644
index 0000000000..55c572cad2
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/_styles.scss
@@ -0,0 +1,25 @@
+.software-version-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;
+ }
+ }
+
+ // used to position header text with premium icon correctly
+ .column-header {
+ display: flex;
+ gap: $pad-small;
+ }
+}
diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/index.ts b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/index.ts
new file mode 100644
index 0000000000..f2db109234
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwareVersionDetailsPage";
diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersions.tsx
new file mode 100644
index 0000000000..1d1aeb3e86
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersions.tsx
@@ -0,0 +1,318 @@
+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 (
+
+ );
+ };
+
+ const renderSoftwareCount = () => {
+ const itemText = getItemsCountText();
+ const lastUpdatedText = getLastUpdatedText();
+
+ if (!itemText) return null;
+
+ return (
+
+ {itemText}
+ {lastUpdatedText}
+
+ );
+ };
+
+ const renderVulnFilterDropdown = () => {
+ return (
+
+ );
+ };
+
+ const renderTableFooter = () => {
+ return (
+
+ Seeing unexpected software or vulnerabilities?{" "}
+
+
+ );
+ };
+
+ if (isSoftwareVersionsError) {
+ return ;
+ }
+
+ return (
+
+
+
(
+
+ )}
+ 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}
+ />
+
+
+ );
+};
+
+export default SoftwareVersions;
diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersionsTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersionsTableConfig.tsx
new file mode 100644
index 0000000000..28ef2b9bf0
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersionsTableConfig.tsx
@@ -0,0 +1,160 @@
+import React from "react";
+import { Column } from "react-table";
+import { InjectedRouter } from "react-router";
+
+import {
+ formatSoftwareType,
+ ISoftwareVersion,
+ ISoftwareVulnerability,
+} from "interfaces/software";
+import PATHS from "router/paths";
+
+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";
+
+// NOTE: cellProps come from react-table
+// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
+interface ICellProps {
+ cell: {
+ value: number | string | ISoftwareVulnerability[];
+ };
+ row: {
+ original: ISoftwareVersion;
+ };
+}
+interface IStringCellProps extends ICellProps {
+ cell: {
+ value: string;
+ };
+}
+
+interface IVersionCellProps extends ICellProps {
+ cell: {
+ value: string;
+ };
+}
+
+interface INumberCellProps extends ICellProps {
+ cell: {
+ value: number;
+ };
+}
+
+interface IVulnCellProps extends ICellProps {
+ cell: {
+ value: ISoftwareVulnerability[];
+ };
+}
+interface IHeaderProps {
+ column: {
+ title: string;
+ isSortedDesc: boolean;
+ };
+}
+
+const generateTableHeaders = (
+ router: InjectedRouter,
+ isPremiumTier?: boolean,
+ isSandboxMode?: boolean,
+ teamId?: number
+): Column[] => {
+ const softwareTableHeaders = [
+ {
+ title: "Name",
+ Header: (cellProps: IHeaderProps): JSX.Element => (
+
+ ),
+ disableSortBy: false,
+ accessor: "name",
+ Cell: (cellProps: IStringCellProps): JSX.Element => {
+ const { id, name, source } = cellProps.row.original;
+
+ const onClickSoftware = (e: React.MouseEvent) => {
+ // Allows for button to be clickable in a clickable row
+ e.stopPropagation();
+ router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString()));
+ };
+
+ return (
+
+
+ {name}
+ >
+ }
+ />
+ );
+ },
+ sortType: "caseInsensitive",
+ },
+ {
+ title: "Version",
+ Header: "Version",
+ disableSortBy: true,
+ accessor: "version",
+ Cell: (cellProps: IVersionCellProps): JSX.Element => (
+
+ ),
+ },
+ {
+ title: "Type",
+ Header: "Type",
+ disableSortBy: true,
+ accessor: "source",
+ Cell: (cellProps: IStringCellProps): JSX.Element => (
+
+ ),
+ },
+ {
+ title: "Vulnerabilities",
+ Header: "Vulnerabilities",
+ disableSortBy: true,
+ accessor: "vulnerabilities",
+ Cell: (cellProps: IVulnCellProps): JSX.Element => (
+
+ ),
+ },
+ {
+ title: "Hosts",
+ Header: (cellProps: IHeaderProps): JSX.Element => (
+
+ ),
+ disableSortBy: false,
+ accessor: "hosts_count",
+ Cell: (cellProps: INumberCellProps): JSX.Element => (
+
+
+
+
+
+
+
+
+ ),
+ },
+ ];
+
+ return softwareTableHeaders;
+};
+
+export default generateTableHeaders;
diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss b/frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss
new file mode 100644
index 0000000000..f7087cfcca
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss
@@ -0,0 +1,166 @@
+.software-versions {
+ 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
+ .version__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) {
+ .version__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;
+ }
+}
diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/index.ts b/frontend/pages/SoftwarePage/SoftwareVersions/index.ts
new file mode 100644
index 0000000000..cdd1fa3a9c
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareVersions/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwareVersions";
diff --git a/frontend/pages/SoftwarePage/_styles.scss b/frontend/pages/SoftwarePage/_styles.scss
new file mode 100644
index 0000000000..0f9fe85494
--- /dev/null
+++ b/frontend/pages/SoftwarePage/_styles.scss
@@ -0,0 +1,59 @@
+.software-page {
+ &__header-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 38px;
+
+ .button-wrap {
+ display: flex;
+ justify-content: flex-end;
+ min-width: 266px;
+ }
+ }
+
+ &__manage-automations {
+ padding: $pad-small $pad-medium;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+
+ .form-field {
+ margin-bottom: 0;
+ }
+ }
+
+ &__text {
+ margin-right: $pad-large;
+ }
+
+ &__title {
+ font-size: $large;
+ }
+
+ &__description {
+ margin: 0;
+ margin-bottom: $pad-large;
+ max-width: 75%;
+
+ @media (min-width: $break-md) {
+ max-width: none;
+ }
+
+ h2 {
+ text-transform: uppercase;
+ color: $core-fleet-black;
+ font-weight: $regular;
+ font-size: $small;
+ }
+
+ p {
+ color: $ui-fleet-black-75;
+ margin: 0;
+ font-size: $x-small;
+ font-style: italic;
+ }
+ }
+}
diff --git a/frontend/pages/software/components/EmptySoftwareTable.tsx b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/EmptySoftwareTable.tsx
similarity index 94%
rename from frontend/pages/software/components/EmptySoftwareTable.tsx
rename to frontend/pages/SoftwarePage/components/EmptySoftwareTable/EmptySoftwareTable.tsx
index 8caf3b8bc2..1596ea750b 100644
--- a/frontend/pages/software/components/EmptySoftwareTable.tsx
+++ b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/EmptySoftwareTable.tsx
@@ -1,4 +1,5 @@
-// This component is used on DashboardPage.tsx > Software.tsx, Host Details/Device User > Software.tsx, and ManageSoftwarePage.tsx
+// This component is used on DashboardPage.tsx > Software.tsx,
+// Host Details / Device User > Software.tsx, and SoftwarePage.tsx
import React from "react";
diff --git a/frontend/pages/SoftwarePage/components/EmptySoftwareTable/index.ts b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/index.ts
new file mode 100644
index 0000000000..17fa4e965d
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/index.ts
@@ -0,0 +1 @@
+export { default } from "./EmptySoftwareTable";
diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx b/frontend/pages/SoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx
similarity index 100%
rename from frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx
rename to frontend/pages/SoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx
diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/SoftwarePage/components/ManageAutomationsModal/_styles.scss
similarity index 100%
rename from frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss
rename to frontend/pages/SoftwarePage/components/ManageAutomationsModal/_styles.scss
diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/index.ts b/frontend/pages/SoftwarePage/components/ManageAutomationsModal/index.ts
similarity index 100%
rename from frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/index.ts
rename to frontend/pages/SoftwarePage/components/ManageAutomationsModal/index.ts
diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx
similarity index 88%
rename from frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx
rename to frontend/pages/SoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx
index 30b4a5fb08..eeb5a96537 100644
--- a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx
+++ b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx
@@ -1,8 +1,9 @@
import React, { useContext } from "react";
-import { syntaxHighlight } from "utilities/helpers";
import { AppContext } from "context/app";
-import { IVulnerability } from "interfaces/vulnerability";
+import { syntaxHighlight } from "utilities/helpers";
+import { ISoftwareVulnerability } from "interfaces/software";
+
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
@@ -12,9 +13,21 @@ const baseClass = "preview-data-modal";
interface IPreviewPayloadModalProps {
onCancel: () => void;
}
+
+interface IHostsAffected {
+ id: number;
+ display_name: string;
+ url: string;
+ software_installed_paths?: string[];
+}
+
+type IWebhookPayload = {
+ hosts_affected?: IHostsAffected[] | null;
+} & ISoftwareVulnerability;
+
interface IJsonPayload {
timestamp: string;
- vulnerability: IVulnerability;
+ vulnerability: IWebhookPayload;
}
const PreviewPayloadModal = ({
diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/_styles.scss b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/_styles.scss
similarity index 100%
rename from frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/_styles.scss
rename to frontend/pages/SoftwarePage/components/PreviewPayloadModal/_styles.scss
diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/index.ts b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/index.ts
similarity index 100%
rename from frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/index.ts
rename to frontend/pages/SoftwarePage/components/PreviewPayloadModal/index.ts
diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx b/frontend/pages/SoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx
similarity index 79%
rename from frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx
rename to frontend/pages/SoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx
index f531d2023d..4197119b64 100644
--- a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx
+++ b/frontend/pages/SoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx
@@ -6,10 +6,10 @@ import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
-import JiraPreview from "../../../../../../assets/images/jira-vuln-software-preview-400x517@2x.png";
-import ZendeskPreview from "../../../../../../assets/images/zendesk-vuln-software-preview-400x455@2x.png";
-import JiraPreviewPremium from "../../../../../../assets/images/jira-vuln-software-preview-premium-400x517@2x.png";
-import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-vuln-software-preview-premium-400x455@2x.png";
+import JiraPreview from "../../../../../assets/images/jira-vuln-software-preview-400x517@2x.png";
+import ZendeskPreview from "../../../../../assets/images/zendesk-vuln-software-preview-400x455@2x.png";
+import JiraPreviewPremium from "../../../../../assets/images/jira-vuln-software-preview-premium-400x517@2x.png";
+import ZendeskPreviewPremium from "../../../../../assets/images/zendesk-vuln-software-preview-premium-400x455@2x.png";
const baseClass = "preview-ticket-modal";
diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/_styles.scss b/frontend/pages/SoftwarePage/components/PreviewTicketModal/_styles.scss
similarity index 100%
rename from frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/_styles.scss
rename to frontend/pages/SoftwarePage/components/PreviewTicketModal/_styles.scss
diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/index.ts b/frontend/pages/SoftwarePage/components/PreviewTicketModal/index.ts
similarity index 100%
rename from frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/index.ts
rename to frontend/pages/SoftwarePage/components/PreviewTicketModal/index.ts
diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
new file mode 100644
index 0000000000..f1b5a39aa1
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
@@ -0,0 +1,69 @@
+import ViewAllHostsLink from "components/ViewAllHostsLink";
+import React from "react";
+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 (
+
+
{title}
+ {value}
+
+ );
+};
+
+interface ISoftwareDetailsSummaryProps {
+ id: number;
+ title: string;
+ type: string;
+ hosts: number;
+ /** The query param name that will be added when user clicks on "View all hosts" link */
+ queryParam: string;
+ name?: string;
+ source?: string;
+ versions?: number;
+}
+
+const SoftwareDetailsSummary = ({
+ id,
+ title,
+ type,
+ hosts,
+ queryParam,
+ name,
+ source,
+ versions,
+}: ISoftwareDetailsSummaryProps) => {
+ return (
+
+
+
+ {title}
+
+
+ {versions && }
+
+
+
+
+
+
+
+ );
+};
+
+export default SoftwareDetailsSummary;
diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss
new file mode 100644
index 0000000000..a0456417b1
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss
@@ -0,0 +1,40 @@
+.software-details-summary {
+ background-color: $core-white;
+ padding: $pad-xxlarge;
+ border: 1px solid $ui-fleet-black-10;
+ border-radius: $border-radius-xxlarge;
+ box-shadow: $box-shadow;
+ display: flex;
+ gap: $pad-medium;
+
+ &__icon {
+ width: 96px;
+ height: 96px;
+ border: 1px solid #E2E4EA;
+ border-radius: 8px;
+ }
+
+ &__info {
+ flex-grow: 1;
+ }
+
+ h1 {
+ font-size: $pad-large;
+ font-weight: bold;
+ margin-bottom: $pad-medium;
+ }
+
+
+ &__description-list {
+ display: flex;
+ gap: $pad-xxlarge;
+ }
+
+ &__data-set {
+ font-size: $x-small;
+
+ dt {
+ font-weight: $bold;
+ }
+ }
+}
diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/index.ts b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/index.ts
new file mode 100644
index 0000000000..0fe352aa8f
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwareDetailsSummary";
diff --git a/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx
new file mode 100644
index 0000000000..db8f4370c9
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx
@@ -0,0 +1,67 @@
+import React from "react";
+import { uniqueId } from "lodash";
+
+import { ISoftwareTitleVersion } from "interfaces/software";
+
+import TextCell from "components/TableContainer/DataTable/TextCell";
+import ReactTooltip from "react-tooltip";
+
+const baseClass = "version-cell";
+
+const generateText = (versions: ISoftwareTitleVersion[]) => {
+ const text =
+ versions.length !== 1 ? `${versions.length} versions` : versions[0].version;
+ return ;
+};
+
+const generateTooltip = (
+ versions: ISoftwareTitleVersion[],
+ tooltipId: string
+) => {
+ if (versions.length <= 1) {
+ return null;
+ }
+
+ const versionNames = versions.map((version) => version.version);
+
+ return (
+
+ {versionNames.join(", ")}
+
+ );
+};
+
+interface IVersionCellProps {
+ versions: ISoftwareTitleVersion[];
+}
+
+const VersionCell = ({ versions }: IVersionCellProps) => {
+ const tooltipId = uniqueId();
+
+ // only one version, no need for tooltip
+ const cellText = generateText(versions);
+ if (versions.length <= 1) {
+ return <>{cellText}>;
+ }
+
+ const versionTooltip = generateTooltip(versions, tooltipId);
+ return (
+ <>
+
+ {cellText}
+
+ {versionTooltip}
+ >
+ );
+};
+
+export default VersionCell;
diff --git a/frontend/pages/SoftwarePage/components/VersionCell/_styles.scss b/frontend/pages/SoftwarePage/components/VersionCell/_styles.scss
new file mode 100644
index 0000000000..2f0275a50d
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/VersionCell/_styles.scss
@@ -0,0 +1,10 @@
+.version-cell {
+
+ &__version-text-with-tooltip {
+ display: inline-block;
+ }
+
+ &__versions {
+ margin: 0;
+ }
+}
diff --git a/frontend/pages/SoftwarePage/components/VersionCell/index.ts b/frontend/pages/SoftwarePage/components/VersionCell/index.ts
new file mode 100644
index 0000000000..bd8a77e877
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/VersionCell/index.ts
@@ -0,0 +1 @@
+export { default } from "./VersionCell";
diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx
new file mode 100644
index 0000000000..51183cacda
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx
@@ -0,0 +1,114 @@
+import React from "react";
+import { uniqueId } from "lodash";
+import ReactTooltip from "react-tooltip";
+
+import TextCell from "components/TableContainer/DataTable/TextCell";
+import { ISoftwareVulnerability } from "interfaces/software";
+
+const NUM_VULNERABILITIES_IN_TOOLTIP = 3;
+
+const baseClass = "vulnerabilities-cell";
+
+const generateCell = (
+ vulnerabilities: ISoftwareVulnerability[] | string[] | null
+) => {
+ if (vulnerabilities === null) {
+ return ;
+ }
+
+ let text = "";
+ let isGrayed = true;
+ if (vulnerabilities.length === 0) {
+ text = "---";
+ } else if (vulnerabilities.length === 1) {
+ isGrayed = false;
+ text =
+ typeof vulnerabilities[0] === "string"
+ ? vulnerabilities[0]
+ : vulnerabilities[0].cve;
+ } else {
+ text = `${vulnerabilities.length} vulnerabilities`;
+ }
+
+ return ;
+};
+
+const getName = (vulnerabiltiy: ISoftwareVulnerability | string) => {
+ return typeof vulnerabiltiy === "string" ? vulnerabiltiy : vulnerabiltiy.cve;
+};
+
+const condenseVulnerabilities = (
+ vulnerabilities: ISoftwareVulnerability[] | string[]
+) => {
+ const condensed =
+ (vulnerabilities?.length &&
+ vulnerabilities
+ .slice(-NUM_VULNERABILITIES_IN_TOOLTIP)
+ .map(getName)
+ .reverse()) ||
+ [];
+
+ return vulnerabilities.length > NUM_VULNERABILITIES_IN_TOOLTIP
+ ? condensed.concat(
+ `+${vulnerabilities.length - NUM_VULNERABILITIES_IN_TOOLTIP} more`
+ )
+ : condensed;
+};
+
+const generateTooltip = (
+ vulnerabilities: ISoftwareVulnerability[] | string[],
+ tooltipId: string
+) => {
+ if (vulnerabilities.length <= 1) {
+ return null;
+ }
+
+ const condensedVulnerabilties = condenseVulnerabilities(vulnerabilities);
+
+ return (
+
+
+ {condensedVulnerabilties.map((vulnerability) => {
+ return • {vulnerability} ;
+ })}
+
+
+ );
+};
+interface IVulnerabilitiesCellProps {
+ vulnerabilities: ISoftwareVulnerability[] | string[] | null;
+}
+
+const VulnerabilitiesCell = ({
+ vulnerabilities,
+}: IVulnerabilitiesCellProps) => {
+ const tooltipId = uniqueId();
+
+ // only one vulnerability, no need for tooltip
+ const cell = generateCell(vulnerabilities);
+ if (vulnerabilities === null || vulnerabilities.length <= 1) {
+ return <>{cell}>;
+ }
+
+ const vulnerabilityTooltip = generateTooltip(vulnerabilities, tooltipId);
+
+ return (
+ <>
+
+ {cell}
+
+ {vulnerabilityTooltip}
+ >
+ );
+};
+
+export default VulnerabilitiesCell;
diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/_styles.scss b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/_styles.scss
new file mode 100644
index 0000000000..a1f3c87066
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/_styles.scss
@@ -0,0 +1,12 @@
+.vulnerabilities-cell {
+ &__vulnerability-text-with-tooltip {
+ display: inline-block;
+ }
+
+ &__vulnerability-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ text-align: left;
+ }
+}
diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/index.ts b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/index.ts
new file mode 100644
index 0000000000..82f8dfb1aa
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/index.ts
@@ -0,0 +1 @@
+export { default } from "./VulnerabilitiesCell";
diff --git a/frontend/pages/SoftwarePage/components/icons/AcrobatReader.tsx b/frontend/pages/SoftwarePage/components/icons/AcrobatReader.tsx
new file mode 100644
index 0000000000..440db6f583
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/AcrobatReader.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const AcrobatReader = (props: SVGProps) => (
+
+
+
+
+);
+export default AcrobatReader;
diff --git a/frontend/pages/SoftwarePage/components/icons/Chrome.tsx b/frontend/pages/SoftwarePage/components/icons/Chrome.tsx
new file mode 100644
index 0000000000..4a4df76d39
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Chrome.tsx
@@ -0,0 +1,71 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Chrome = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default Chrome;
diff --git a/frontend/pages/SoftwarePage/components/icons/Excel.tsx b/frontend/pages/SoftwarePage/components/icons/Excel.tsx
new file mode 100644
index 0000000000..5a46cfbc23
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Excel.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Excel = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default Excel;
diff --git a/frontend/pages/SoftwarePage/components/icons/Extension.tsx b/frontend/pages/SoftwarePage/components/icons/Extension.tsx
new file mode 100644
index 0000000000..d456c8724f
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Extension.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Extension = (props: SVGProps) => (
+
+
+
+
+);
+export default Extension;
diff --git a/frontend/pages/SoftwarePage/components/icons/Firefox.tsx b/frontend/pages/SoftwarePage/components/icons/Firefox.tsx
new file mode 100644
index 0000000000..be5594c4e6
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Firefox.tsx
@@ -0,0 +1,292 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Firefox = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default Firefox;
diff --git a/frontend/pages/SoftwarePage/components/icons/MacApp.tsx b/frontend/pages/SoftwarePage/components/icons/MacApp.tsx
new file mode 100644
index 0000000000..5119e03fb1
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/MacApp.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const MacApp = (props: SVGProps) => (
+
+
+
+
+);
+export default MacApp;
diff --git a/frontend/pages/SoftwarePage/components/icons/Package.tsx b/frontend/pages/SoftwarePage/components/icons/Package.tsx
new file mode 100644
index 0000000000..8a2aae8db9
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Package.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Package = (props: SVGProps) => (
+
+
+
+
+);
+export default Package;
diff --git a/frontend/pages/SoftwarePage/components/icons/Safari.tsx b/frontend/pages/SoftwarePage/components/icons/Safari.tsx
new file mode 100644
index 0000000000..c0c28e3a8c
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Safari.tsx
@@ -0,0 +1,584 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Safari = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default Safari;
diff --git a/frontend/pages/SoftwarePage/components/icons/Slack.tsx b/frontend/pages/SoftwarePage/components/icons/Slack.tsx
new file mode 100644
index 0000000000..3b6aac63d7
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Slack.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Slack = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default Slack;
diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx
new file mode 100644
index 0000000000..e99b23e262
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx
@@ -0,0 +1,64 @@
+import React, { ComponentType, SVGProps } from "react";
+import {
+ SOFTWARE_NAME_TO_ICON_MAP,
+ SOFTWARE_SOURCE_TO_ICON_MAP,
+ SOFTWARE_ICON_SIZES,
+ SoftwareIconSizes,
+} from "../";
+
+const baseClass = "software-icon";
+
+interface ISoftwareIconProps {
+ name?: string;
+ source?: string;
+ size?: SoftwareIconSizes;
+}
+
+const matchInMap = (
+ map: Record>>,
+ potentialKey?: string
+) => {
+ if (!potentialKey) {
+ return null;
+ }
+
+ const sanitizedKey = potentialKey.trim().toLowerCase();
+ const match = Object.entries(map).find(([namePrefix, icon]) => {
+ if (sanitizedKey.startsWith(namePrefix)) {
+ return icon;
+ }
+ return null;
+ });
+
+ return match ? match[1] : null;
+};
+
+const SoftwareIcon = ({
+ name,
+ source,
+ size = "medium",
+}: ISoftwareIconProps) => {
+ // try to find a match for name
+ let MatchedIcon = matchInMap(SOFTWARE_NAME_TO_ICON_MAP, name);
+
+ // otherwise, try to find a match for source
+ if (!MatchedIcon) {
+ MatchedIcon = matchInMap(SOFTWARE_SOURCE_TO_ICON_MAP, source);
+ }
+
+ // default to 'package'
+ if (!MatchedIcon) {
+ MatchedIcon = SOFTWARE_SOURCE_TO_ICON_MAP.package;
+ }
+
+ return (
+
+ );
+};
+
+export default SoftwareIcon;
diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/index.ts b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/index.ts
new file mode 100644
index 0000000000..202b202e40
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwareIcon";
diff --git a/frontend/pages/SoftwarePage/components/icons/Teams.tsx b/frontend/pages/SoftwarePage/components/icons/Teams.tsx
new file mode 100644
index 0000000000..dfb64170cb
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Teams.tsx
@@ -0,0 +1,84 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Teams = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default Teams;
diff --git a/frontend/pages/SoftwarePage/components/icons/VisualStudioCode.tsx b/frontend/pages/SoftwarePage/components/icons/VisualStudioCode.tsx
new file mode 100644
index 0000000000..ac089803e2
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/VisualStudioCode.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const VisualStudioCode = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default VisualStudioCode;
diff --git a/frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx b/frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx
new file mode 100644
index 0000000000..6f0fad5a90
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const WindowsApp = (props: SVGProps) => (
+
+
+
+
+);
+export default WindowsApp;
diff --git a/frontend/pages/SoftwarePage/components/icons/Word.tsx b/frontend/pages/SoftwarePage/components/icons/Word.tsx
new file mode 100644
index 0000000000..4a654107b2
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Word.tsx
@@ -0,0 +1,94 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Word = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+export default Word;
diff --git a/frontend/pages/SoftwarePage/components/icons/Zoom.tsx b/frontend/pages/SoftwarePage/components/icons/Zoom.tsx
new file mode 100644
index 0000000000..8b9512751d
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Zoom.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+import type { SVGProps } from "react";
+
+const Zoom = (props: SVGProps) => (
+
+
+
+
+
+
+
+
+
+
+);
+export default Zoom;
diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts
new file mode 100644
index 0000000000..0965c78651
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/index.ts
@@ -0,0 +1,60 @@
+import AcrobatReader from "./AcrobatReader";
+import Chrome from "./Chrome";
+import Excel from "./Excel";
+import Extension from "./Extension";
+import Firefox from "./Firefox";
+import MacApp from "./MacApp";
+import Package from "./Package";
+import Safari from "./Safari";
+import Slack from "./Slack";
+import Teams from "./Teams";
+import VisualStudioCode from "./VisualStudioCode";
+import WindowsApp from "./WindowsApp";
+import Word from "./Word";
+import Zoom from "./Zoom";
+
+// SOFTWARE_NAME_TO_ICON_MAP list "special" applications that have a defined
+// icon for them, keys refer to application names, and are intended to be fuzzy
+// matched in the application logic.
+export const SOFTWARE_NAME_TO_ICON_MAP = {
+ "adobe acrobat reader": AcrobatReader,
+ "google chrome": Chrome,
+ "microsoft excel": Excel,
+ firefox: Firefox,
+ package: Package,
+ safari: Safari,
+ slack: Slack,
+ "microsoft teams": Teams,
+ "visual studio code": VisualStudioCode,
+ "microsoft word": Word,
+ zoom: Zoom,
+} as const;
+
+// SOFTWARE_SOURCE_TO_ICON_MAP maps different software sources to a defined
+// icon.
+export const SOFTWARE_SOURCE_TO_ICON_MAP = {
+ package: Package,
+ apt_sources: Package,
+ deb_packages: Package,
+ rpm_packages: Package,
+ yum_sources: Package,
+ npm_packages: Package,
+ atom_packages: Package,
+ python_packages: Package,
+ homebrew_packages: Package,
+ apps: MacApp,
+ programs: WindowsApp,
+ chrome_extensions: Extension,
+ safari_extensions: Extension,
+ firefox_addons: Extension,
+ ie_extensions: Extension,
+ chocolatey_packages: Package,
+ pkg_packages: Package,
+} as const;
+
+export const SOFTWARE_ICON_SIZES: Record = {
+ medium: "24",
+ large: "96",
+} as const;
+
+export type SoftwareIconSizes = keyof typeof SOFTWARE_ICON_SIZES;
diff --git a/frontend/pages/SoftwarePage/helpers.ts b/frontend/pages/SoftwarePage/helpers.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frontend/pages/SoftwarePage/index.ts b/frontend/pages/SoftwarePage/index.ts
new file mode 100644
index 0000000000..980539c402
--- /dev/null
+++ b/frontend/pages/SoftwarePage/index.ts
@@ -0,0 +1 @@
+export { default } from "./SoftwarePage";
diff --git a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage/WindowsAutomaticEnrollmentPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage/WindowsAutomaticEnrollmentPage.tsx
index 80743fe5ba..ec8e50b7b1 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage/WindowsAutomaticEnrollmentPage.tsx
+++ b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage/WindowsAutomaticEnrollmentPage.tsx
@@ -50,7 +50,7 @@ const WindowsAutomaticEnrollmentPage = () => {
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/MacOSMdmCard/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/MacOSMdmCard/_styles.scss
index 302a4beb18..53ee9f0ab9 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/MacOSMdmCard/_styles.scss
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/MacOSMdmCard/_styles.scss
@@ -10,6 +10,16 @@
display: flex;
justify-content: space-between;
align-items: center;
+
+ p {
+ margin-right: $pad-small;
+ }
+
+ button {
+ .children-wrapper {
+ text-wrap: nowrap;
+ }
+ }
}
&__turn-on-mac-os {
@@ -25,7 +35,7 @@
}
&__turn-off-mac-os {
- >div {
+ > div {
display: flex;
align-items: center;
}
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/WindowsMdmCard/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/WindowsMdmCard/_styles.scss
index f157bb1178..79bc9b91b4 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/WindowsMdmCard/_styles.scss
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/WindowsMdmCard/_styles.scss
@@ -5,10 +5,21 @@
margin: 0;
}
- &__turn-on-windows, &__turn-off-windows {
+ &__turn-on-windows,
+ &__turn-off-windows {
display: flex;
justify-content: space-between;
align-items: center;
+
+ p {
+ margin-right: $pad-small;
+ }
+
+ button {
+ .children-wrapper {
+ text-wrap: nowrap;
+ }
+ }
}
&__turn-on-windows {
diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
index 3e04264361..af06b76bf8 100644
--- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
+++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
@@ -219,6 +219,14 @@ const ManageHostsPage = ({
queryParams?.software_id !== undefined
? parseInt(queryParams.software_id, 10)
: undefined;
+ const softwareVersionId =
+ queryParams?.software_version_id !== undefined
+ ? parseInt(queryParams.software_version_id, 10)
+ : undefined;
+ const softwareTitleId =
+ queryParams?.software_title_id !== undefined
+ ? parseInt(queryParams.software_title_id, 10)
+ : undefined;
const status = isAcceptableStatus(queryParams?.status)
? queryParams?.status
: undefined;
@@ -360,6 +368,8 @@ const ManageHostsPage = ({
policyId,
policyResponse,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@@ -400,6 +410,8 @@ const ManageHostsPage = ({
policyId,
policyResponse,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@@ -491,7 +503,13 @@ const ManageHostsPage = ({
// TODO: cleanup this effect
useEffect(() => {
- if (location.search.includes("software_id")) {
+ if (
+ location.search.match(
+ /software_id|software_version_id|software_title_id/gi
+ )
+ ) {
+ // regex matches any of "software_id", "software_version_id", or "software_title_id"
+ // so we don't set the filtered hosts path in those cases
return;
}
const path = location.pathname + location.search;
@@ -520,6 +538,8 @@ const ManageHostsPage = ({
"policy_id",
"policy_response",
"software_id",
+ "software_version_id",
+ "software_title_id",
]);
}
@@ -783,6 +803,10 @@ const ManageHostsPage = ({
newQueryParams.macos_settings = macSettingsStatus;
} else if (softwareId) {
newQueryParams.software_id = softwareId;
+ } else if (softwareVersionId) {
+ newQueryParams.software_version_id = softwareVersionId;
+ } else if (softwareTitleId) {
+ newQueryParams.software_title_id = softwareTitleId;
} else if (mdmId) {
newQueryParams.mdm_id = mdmId;
} else if (mdmEnrollmentStatus) {
@@ -828,6 +852,8 @@ const ManageHostsPage = ({
policyResponse,
macSettingsStatus,
softwareId,
+ softwareVersionId,
+ softwareTitleId,
mdmId,
mdmEnrollmentStatus,
munkiIssueId,
@@ -1244,6 +1270,8 @@ const ManageHostsPage = ({
policyResponse,
macSettingsStatus,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@@ -1557,6 +1585,8 @@ const ManageHostsPage = ({
policy,
macSettingsStatus,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
mdmId,
mdmEnrollmentStatus,
lowDiskSpaceHosts,
@@ -1566,7 +1596,8 @@ const ManageHostsPage = ({
osVersions,
munkiIssueId,
munkiIssueDetails: hostsData?.munki_issue || null,
- softwareDetails: hostsData?.software || null,
+ softwareDetails:
+ hostsData?.software || hostsData?.software_title || null,
mdmSolutionDetails:
hostsData?.mobile_device_management_solution || null,
osSettingsStatus,
diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx
index 1c45076031..f504b6556d 100644
--- a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx
+++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx
@@ -53,7 +53,9 @@ interface IHostsFilterBlockProps {
policyId?: any;
policy?: IPolicy;
macSettingsStatus?: any;
- softwareId?: any;
+ softwareId?: number;
+ softwareTitleId?: number;
+ softwareVersionId?: number;
mdmId?: number;
mdmEnrollmentStatus?: any;
lowDiskSpaceHosts?: number;
@@ -62,7 +64,7 @@ interface IHostsFilterBlockProps {
osVersion?: any;
munkiIssueId?: number;
osVersions?: IOperatingSystemVersion[];
- softwareDetails: ISoftware | null;
+ softwareDetails: { name: string; version?: string } | null;
mdmSolutionDetails: IMdmSolution | null;
osSettingsStatus?: MdmProfileStatus;
diskEncryptionStatus?: DiskEncryptionStatus;
@@ -95,6 +97,8 @@ const HostsFilterBlock = ({
policyId,
macSettingsStatus,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
mdmId,
mdmEnrollmentStatus,
lowDiskSpaceHosts,
@@ -235,21 +239,31 @@ const HostsFilterBlock = ({
if (!softwareDetails) return null;
const { name, version } = softwareDetails;
- const label = `${name || "Unknown software"} ${version || ""}`;
+ let label = name;
+ if (version) {
+ label += ` ${version}`;
+ }
+ label = label.trim() || "Unknown software";
- const TooltipDescription = (
-
- Hosts with {name || "Unknown software"},
-
- {version || "version unknown"} installed
-
- );
+ // const TooltipDescription = (
+ //
+ // Hosts with {name || "Unknown software"},
+ //
+ // {version || "version unknown"} installed
+ //
+ // );
return (
handleClearFilter(["software_id"])}
- tooltipDescription={TooltipDescription}
+ onClear={() =>
+ handleClearFilter([
+ "software_id",
+ "software_version_id",
+ "software_title_id",
+ ])
+ }
+ // tooltipDescription={TooltipDescription}
/>
);
};
@@ -433,6 +447,8 @@ const HostsFilterBlock = ({
policyId ||
macSettingsStatus ||
softwareId ||
+ softwareTitleId ||
+ softwareVersionId ||
mdmId ||
mdmEnrollmentStatus ||
lowDiskSpaceHosts ||
@@ -472,7 +488,7 @@ const HostsFilterBlock = ({
return renderPoliciesFilterBlock();
case !!macSettingsStatus:
return renderMacSettingsStatusFilterBlock();
- case !!softwareId:
+ case !!softwareId || !!softwareVersionId || !!softwareTitleId:
return renderSoftwareFilterBlock();
case !!mdmId:
return renderMDMSolutionFilterBlock();
diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx
index 1046822673..b453ebc83c 100644
--- a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx
+++ b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx
@@ -58,9 +58,6 @@ const DiskEncryptionKeyModal = ({
const recoveryText = isMacOS
? "Use this key to log in to the host if you forgot the password."
: "Use this key to unlock the encrypted drive.";
- const recoveryUrl = isMacOS
- ? "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#reset-a-macos-hosts-password-using-the-disk-encryption-key"
- : "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#unlock-a-windows-hosts-drive-using-the-disk-encryption-key";
return (
@@ -70,14 +67,7 @@ const DiskEncryptionKeyModal = ({
<>
{descriptionText}
-
- {recoveryText}{" "}
-
-
+ {recoveryText}
Done
diff --git a/frontend/pages/hosts/details/cards/Scripts/_styles.scss b/frontend/pages/hosts/details/cards/Scripts/_styles.scss
index 0c4f9ccfc3..0bdc86c03c 100644
--- a/frontend/pages/hosts/details/cards/Scripts/_styles.scss
+++ b/frontend/pages/hosts/details/cards/Scripts/_styles.scss
@@ -9,6 +9,18 @@
line-height: 1.5;
}
+ .table-container {
+ .name__header {
+ width: 50%;
+ }
+ .last_execution__header {
+ width: 25%;
+ }
+ .actions__header {
+ width: 25%;
+ }
+ }
+
.table-container__header-left {
display: block;
}
diff --git a/frontend/pages/hosts/details/cards/Software/Software.tsx b/frontend/pages/hosts/details/cards/Software/Software.tsx
index 021296a23a..00aae4e5ab 100644
--- a/frontend/pages/hosts/details/cards/Software/Software.tsx
+++ b/frontend/pages/hosts/details/cards/Software/Software.tsx
@@ -13,7 +13,7 @@ import { buildQueryStringFromParams } from "utilities/url";
import Dropdown from "components/forms/fields/Dropdown";
import TableContainer from "components/TableContainer";
import { ITableQueryData } from "components/TableContainer/TableContainer";
-import EmptySoftwareTable from "pages/software/components/EmptySoftwareTable";
+import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
import { getNextLocationPath } from "utilities/helpers";
import SoftwareVulnCount from "./SoftwareVulnCount";
diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx
index 63a67a25a3..d50abb64ff 100644
--- a/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx
@@ -211,12 +211,12 @@ export const generateSoftwareTableHeaders = ({
// Allows for button to be clickable in a clickable row
e.stopPropagation();
setFilteredSoftwarePath(pathname);
- router?.push(PATHS.SOFTWARE_DETAILS(id.toString()));
+ router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString()));
};
return (
{
- const routeTemplate = route?.path ?? "";
- const queryParams = location.query;
- const {
- config: globalConfig,
- isFreeTier,
- isGlobalAdmin,
- isGlobalMaintainer,
- isOnGlobalTeam,
- isPremiumTier,
- isSandboxMode,
- noSandboxHosts,
- filteredSoftwarePath,
- setFilteredSoftwarePath,
- } = useContext(AppContext);
- const { renderFlash } = useContext(NotificationContext);
-
- const {
- currentTeamId,
- isAnyTeamSelected,
- isRouteOk,
- teamIdForApi,
- userTeams,
- handleTeamChange,
- } = useTeamIdParam({
- location,
- router,
- includeAllTeams: true,
- includeNoTeam: false,
- });
-
- const canManageAutomations =
- isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
-
- const DEFAULT_SORT_HEADER = isPremiumTier ? "vulnerabilities" : "hosts_count";
-
- const initialQuery = (() => {
- let query = "";
-
- if (queryParams && queryParams.query) {
- query = queryParams.query;
- }
-
- return query;
- })();
-
- const initialSortHeader = (() => {
- let sortHeader = isPremiumTier ? "vulnerabilities" : "hosts_count";
-
- if (queryParams && queryParams.order_key) {
- sortHeader = queryParams.order_key;
- }
-
- return sortHeader;
- })();
-
- const initialSortDirection = ((): "asc" | "desc" | undefined => {
- let sortDirection = "desc";
-
- if (queryParams && queryParams.order_direction) {
- sortDirection = queryParams.order_direction;
- }
-
- return sortDirection as "asc" | "desc" | undefined;
- })();
-
- const initialPage = (() => {
- let page = 0;
-
- if (queryParams && queryParams.page) {
- page = parseInt(queryParams.page, 10);
- }
-
- return page;
- })();
-
- const initialVulnFilter = (() => {
- let isFilteredByVulnerabilities = false;
-
- if (queryParams && queryParams.vulnerable === "true") {
- isFilteredByVulnerabilities = true;
- }
-
- return isFilteredByVulnerabilities;
- })();
-
- const [filterVuln, setFilterVuln] = useState(initialVulnFilter);
- const [searchQuery, setSearchQuery] = useState(initialQuery);
- const [sortDirection, setSortDirection] = useState<
- "asc" | "desc" | undefined
- >(initialSortDirection);
- const [sortHeader, setSortHeader] = useState(initialSortHeader);
- const [page, setPage] = useState(initialPage);
- const [tableQueryData, setTableQueryData] = useState();
- const [resetPageIndex, setResetPageIndex] = useState(false);
- const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
- false
- );
- const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
- const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
-
- useEffect(() => {
- setFilterVuln(initialVulnFilter);
- setPage(initialPage);
- setSearchQuery(initialQuery);
- // TODO: handle invalid values for params
- }, [location]);
-
- useEffect(() => {
- const path = location.pathname + location.search;
- if (filteredSoftwarePath !== path) {
- setFilteredSoftwarePath(path);
- }
- }, [filteredSoftwarePath, location, setFilteredSoftwarePath]);
-
- // softwareConfig is either the global config or the team config of the currently selected team
- const {
- data: softwareConfig,
- error: softwareConfigError,
- isFetching: isFetchingSoftwareConfig,
- refetch: refetchSoftwareConfig,
- } = useQuery<
- IConfig | ILoadTeamResponse,
- Error,
- IConfig | ITeamConfig,
- ISoftwareConfigQueryKey[]
- >(
- [{ scope: "softwareConfig", teamId: teamIdForApi }],
- ({ queryKey }) => {
- const { teamId } = queryKey[0];
- return teamId ? teamsAPI.load(teamId) : configAPI.loadAll();
- },
- {
- enabled: isRouteOk,
- select: (data) => ("team" in data ? data.team : data),
- }
- );
-
- const isSoftwareConfigLoaded =
- !isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
-
- const isSoftwareEnabled = !!softwareConfig?.features
- ?.enable_software_inventory;
-
- const vulnWebhookSettings =
- softwareConfig?.webhook_settings?.vulnerabilities_webhook;
-
- const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook;
-
- const isVulnIntegrationEnabled = (integrations?: IIntegrations) => {
- return (
- !!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) ||
- !!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities)
- );
- };
-
- const isAnyVulnAutomationEnabled =
- isVulnWebhookEnabled ||
- isVulnIntegrationEnabled(softwareConfig?.integrations);
-
- const recentVulnerabilityMaxAge = (() => {
- let maxAgeInNanoseconds: number | undefined;
- if (softwareConfig && "vulnerabilities" in softwareConfig) {
- maxAgeInNanoseconds =
- softwareConfig.vulnerabilities.recent_vulnerability_max_age;
- } else {
- maxAgeInNanoseconds =
- globalConfig?.vulnerabilities.recent_vulnerability_max_age;
- }
- return maxAgeInNanoseconds
- ? Math.round(maxAgeInNanoseconds / 86400000000000) // convert from nanoseconds to days
- : CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS;
- })();
-
- const {
- data: software,
- error: softwareError,
- isFetching: isFetchingSoftware,
- } = useQuery<
- ISoftwareResponse,
- Error,
- ISoftwareResponse,
- ISoftwareQueryKey[]
- >(
- [
- {
- scope: "software",
- page: tableQueryData?.pageIndex,
- perPage: DEFAULT_PAGE_SIZE,
- query: searchQuery,
- orderDirection: sortDirection,
- // API expects "epss_probability" rather than "vulnerabilities"
- orderKey:
- isPremiumTier && sortHeader === "vulnerabilities"
- ? "epss_probability"
- : sortHeader,
- teamId: teamIdForApi,
- vulnerable: filterVuln,
- },
- ],
- ({ queryKey }) => softwareAPI.load(queryKey[0]),
- {
- enabled: isRouteOk && isSoftwareConfigLoaded,
- keepPreviousData: true,
- staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
- }
- );
-
- const {
- data: softwareCount,
- error: softwareCountError,
- isFetching: isFetchingCount,
- } = useQuery(
- [
- {
- scope: "softwareCount",
- query: searchQuery,
- vulnerable: filterVuln,
- teamId: teamIdForApi,
- },
- ],
- ({ queryKey }) => softwareAPI.getCount(queryKey[0]),
- {
- enabled: isRouteOk && isSoftwareConfigLoaded,
- keepPreviousData: true,
- staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
- refetchOnWindowFocus: false,
- retry: 1,
- select: (data) => data.count,
- }
- );
-
- // NOTE: this is called once on initial render and every time the query changes
- const onQueryChange = useCallback(
- async (newTableQuery: ITableQueryData) => {
- if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) {
- return;
- }
-
- setTableQueryData({ ...newTableQuery });
-
- const {
- pageIndex,
- searchQuery: newSearchQuery,
- sortDirection: newSortDirection,
- } = newTableQuery;
- let { sortHeader: newSortHeader } = newTableQuery;
-
- pageIndex !== page && setPage(pageIndex);
- searchQuery !== newSearchQuery && setSearchQuery(newSearchQuery);
- sortDirection !== newSortDirection &&
- setSortDirection(
- newSortDirection === "asc" || newSortDirection === "desc"
- ? newSortDirection
- : DEFAULT_SORT_DIRECTION
- );
-
- if (isPremiumTier && newSortHeader === "vulnerabilities") {
- newSortHeader = "epss_probability";
- }
- sortHeader !== newSortHeader && setSortHeader(newSortHeader);
-
- // Rebuild queryParams to dispatch new browser location to react-router
- const newQueryParams: { [key: string]: string | number | undefined } = {};
- if (!isEmpty(newSearchQuery)) {
- newQueryParams.query = newSearchQuery;
- }
- newQueryParams.page = pageIndex;
- newQueryParams.order_key = newSortHeader || DEFAULT_SORT_HEADER;
- newQueryParams.order_direction =
- newSortDirection || DEFAULT_SORT_DIRECTION;
-
- newQueryParams.vulnerable = filterVuln ? "true" : undefined;
-
- if (teamIdForApi !== undefined) {
- newQueryParams.team_id = teamIdForApi;
- }
-
- const locationPath = getNextLocationPath({
- pathPrefix: PATHS.MANAGE_SOFTWARE,
- routeTemplate,
- queryParams: newQueryParams,
- });
- router.replace(locationPath);
- },
- [
- isRouteOk,
- teamIdForApi,
- tableQueryData,
- page,
- searchQuery,
- sortDirection,
- isPremiumTier,
- sortHeader,
- DEFAULT_SORT_HEADER,
- filterVuln,
- routeTemplate,
- router,
- ]
- );
-
- const toggleManageAutomationsModal = useCallback(() => {
- setShowManageAutomationsModal(!showManageAutomationsModal);
- }, [setShowManageAutomationsModal, showManageAutomationsModal]);
-
- const togglePreviewPayloadModal = useCallback(() => {
- setShowPreviewPayloadModal(!showPreviewPayloadModal);
- }, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
-
- const togglePreviewTicketModal = useCallback(() => {
- setShowPreviewTicketModal(!showPreviewTicketModal);
- }, [setShowPreviewTicketModal, showPreviewTicketModal]);
-
- const onCreateWebhookSubmit = async (
- configSoftwareAutomations: ISoftwareAutomations
- ) => {
- try {
- const request = configAPI.update(configSoftwareAutomations);
- await request.then(() => {
- renderFlash(
- "success",
- "Successfully updated vulnerability automations."
- );
- refetchSoftwareConfig();
- });
- } catch {
- renderFlash(
- "error",
- "Could not update vulnerability automations. Please try again."
- );
- } finally {
- toggleManageAutomationsModal();
- }
- };
-
- const onTeamChange = useCallback(
- (teamId: number) => {
- handleTeamChange(teamId);
- setPage(0);
- },
- [handleTeamChange]
- );
-
- // NOTE: used to reset page number to 0 when modifying filters
- const handleResetPageIndex = () => {
- setTableQueryData(
- (prevState) =>
- ({
- ...prevState,
- pageIndex: 0,
- } as ITableQueryData)
- );
- setResetPageIndex(true);
- };
-
- // NOTE: used to reset page number to 0 when modifying filters
- useEffect(() => {
- // TODO: cleanup this effect
- setResetPageIndex(false);
- }, [queryParams]);
-
- const renderHeaderDescription = () => {
- return (
-
- Search for installed software{" "}
- {(isGlobalAdmin || isGlobalMaintainer) &&
- (!isPremiumTier || !isAnyTeamSelected) &&
- "and manage automations for detected vulnerabilities (CVEs)"}{" "}
- on{" "}
-
- {isPremiumTier && isAnyTeamSelected
- ? "all hosts assigned to this team"
- : "all of your hosts"}
-
- .
-
- );
- };
-
- const renderSoftwareCount = useCallback(() => {
- const count = softwareCount;
- const lastUpdatedAt = software?.counts_updated_at;
-
- if (!isSoftwareEnabled || !lastUpdatedAt) {
- return null;
- }
-
- if (softwareCountError && !isFetchingCount) {
- return (
-
- Failed to load software count
-
- );
- }
-
- if (count) {
- return (
-
- {`${count} software item${count === 1 ? "" : "s"}`}
-
-
- );
- }
-
- return null;
- }, [
- isFetchingCount,
- software,
- softwareCountError,
- softwareCount,
- isSoftwareEnabled,
- ]);
-
- const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
- handleResetPageIndex();
-
- router.replace(
- getNextLocationPath({
- pathPrefix: PATHS.MANAGE_SOFTWARE,
- routeTemplate,
- queryParams: {
- ...queryParams,
- vulnerable: isFilterVulnerable,
- page: 0, // resets page index
- },
- })
- );
- };
-
- const renderVulnFilterDropdown = () => {
- return (
-
- );
- };
-
- const renderTableFooter = () => {
- return (
-
- Seeing unexpected software or vulnerabilities?{" "}
-
-
- );
- };
-
- // TODO: Rework after backend is adjusted to differentiate empty search/filter results from
- // collecting inventory
- const isCollectingInventory =
- !searchQuery &&
- !filterVuln &&
- page === 0 &&
- !software?.software &&
- software?.counts_updated_at === null;
-
- const isLastPage =
- tableQueryData &&
- !!softwareCount &&
- DEFAULT_PAGE_SIZE * page + (software?.software?.length || 0) >=
- softwareCount;
-
- const softwareTableHeaders = useMemo(
- () =>
- generateSoftwareTableHeaders(
- router,
- isPremiumTier,
- isSandboxMode,
- currentTeamId
- ),
- [isPremiumTier, isSandboxMode, router, currentTeamId]
- );
- const onSelectSingleRow = (row: ISoftwareRowProps) => {
- const hostsBySoftwareParams = {
- software_id: row.original.id,
- team_id: currentTeamId,
- };
-
- const path = hostsBySoftwareParams
- ? `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
- hostsBySoftwareParams
- )}`
- : PATHS.MANAGE_HOSTS;
-
- router.push(path);
- };
-
- const searchable =
- isSoftwareEnabled &&
- (!!software?.software ||
- searchQuery !== "" ||
- queryParams.vulnerable === "true");
-
- const renderSoftwareTable = () => {
- if (
- (softwareError && !isFetchingSoftware) ||
- (softwareConfigError && !isFetchingSoftwareConfig)
- ) {
- return ;
- }
- return (
- (
-
- )}
- defaultSortHeader={sortHeader || DEFAULT_SORT_HEADER}
- defaultSortDirection={sortDirection || DEFAULT_SORT_DIRECTION}
- defaultPageIndex={page || 0}
- defaultSearchQuery={searchQuery}
- manualSortBy
- pageSize={DEFAULT_PAGE_SIZE}
- showMarkAllPages={false}
- isAllPagesSelected={false}
- disableNextPage={isLastPage}
- inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
- additionalQueries={filterVuln ? "vulnerable" : ""} // additionalQueries serves as a trigger
- // for the useDeepEffect hook to fire onQueryChange for events happeing outside of
- // the TableContainer
- customControl={searchable ? renderVulnFilterDropdown : undefined}
- stackControls
- renderCount={renderSoftwareCount}
- renderFooter={renderTableFooter}
- disableMultiRowSelect
- {...{ resetPageIndex, searchable, onQueryChange, onSelectSingleRow }}
- />
- );
- };
-
- return (
-
-
-
-
-
-
- {isFreeTier &&
Software }
- {isPremiumTier &&
- ((userTeams && userTeams.length > 1) || isOnGlobalTeam) && (
-
- )}
- {isPremiumTier &&
- !isOnGlobalTeam &&
- userTeams &&
- userTeams.length === 1 && {userTeams[0].name} }
-
-
-
- {canManageAutomations && !softwareError && isSoftwareConfigLoaded && (
-
- Manage automations
-
- )}
-
-
- {renderHeaderDescription()}
-
-
{renderSoftwareTable()}
- {showManageAutomationsModal && (
-
- )}
-
-
- );
-};
-
-export default ManageSoftwarePage;
diff --git a/frontend/pages/software/ManageSoftwarePage/SoftwareTableConfig.tsx b/frontend/pages/software/ManageSoftwarePage/SoftwareTableConfig.tsx
deleted file mode 100644
index 5c141fda58..0000000000
--- a/frontend/pages/software/ManageSoftwarePage/SoftwareTableConfig.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-import React from "react";
-import { Column } from "react-table";
-import { InjectedRouter } from "react-router";
-import ReactTooltip from "react-tooltip";
-
-import { formatSoftwareType, ISoftware } from "interfaces/software";
-import { IVulnerability } from "interfaces/vulnerability";
-import PATHS from "router/paths";
-import {
- formatFloatAsPercentage,
- getSoftwareBundleTooltipJSX,
-} from "utilities/helpers";
-import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
-
-import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
-import TextCell from "components/TableContainer/DataTable/TextCell";
-import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
-import TooltipWrapper from "components/TooltipWrapper";
-import ViewAllHostsLink from "components/ViewAllHostsLink";
-import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
-import { COLORS } from "styles/var/colors";
-
-// NOTE: cellProps come from react-table
-// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
-interface ICellProps {
- cell: {
- value: number | string | IVulnerability[];
- };
- row: {
- original: ISoftware;
- };
-}
-interface IStringCellProps extends ICellProps {
- cell: {
- value: string;
- };
-}
-
-interface INumberCellProps extends ICellProps {
- cell: {
- value: number;
- };
-}
-
-interface IVulnCellProps extends ICellProps {
- cell: {
- value: IVulnerability[];
- };
-}
-interface IHeaderProps {
- column: {
- title: string;
- isSortedDesc: boolean;
- };
-}
-
-const condenseVulnerabilities = (
- vulnerabilities: IVulnerability[]
-): string[] => {
- const condensed =
- (vulnerabilities?.length &&
- vulnerabilities
- .slice(-3)
- .map((v) => v.cve)
- .reverse()) ||
- [];
- return vulnerabilities.length > 3
- ? condensed.concat(`+${vulnerabilities.length - 3} more`)
- : condensed;
-};
-
-const getMaxProbability = (vulns: IVulnerability[]) =>
- vulns.reduce(
- (max, { epss_probability }) => Math.max(max, epss_probability || 0),
- 0
- );
-
-const generateEPSSColumnHeader = (isSandboxMode = false) => {
- return {
- Header: (headerProps: IHeaderProps): JSX.Element => {
- const titleWithToolTip = (
-
- The probability that this software will be exploited
-
- in the next 30 days (EPSS probability). This data is
-
- reported by FIRST.org.
- >
- }
- >
- Probability of exploit
-
- );
- return (
- <>
- {isSandboxMode && }
-
- >
- );
- },
- disableSortBy: false,
- accessor: "vulnerabilities",
- Cell: (cellProps: IVulnCellProps): JSX.Element => {
- const vulns = cellProps.cell.value || [];
- const maxProbability = (!!vulns.length && getMaxProbability(vulns)) || 0;
- const displayValue =
- (maxProbability && formatFloatAsPercentage(maxProbability)) ||
- DEFAULT_EMPTY_CELL_VALUE;
-
- return (
-
- {displayValue}
-
- );
- },
- };
-};
-
-const generateVulnColumnHeader = () => {
- return {
- title: "Vulnerabilities",
- Header: "Vulnerabilities",
- disableSortBy: true,
- accessor: "vulnerabilities",
- Cell: (cellProps: IVulnCellProps): JSX.Element => {
- const vulnerabilities = cellProps.cell.value || [];
- const tooltipText = condenseVulnerabilities(vulnerabilities)?.map(
- (value) => {
- return (
-
- {value}
-
-
- );
- }
- );
-
- if (!vulnerabilities?.length) {
- return --- ;
- }
- return (
- <>
- 1 ? "text-muted tooltip" : ""
- }`}
- data-tip
- data-for={`vulnerabilities__${cellProps.row.original.id}`}
- data-tip-disable={vulnerabilities.length <= 1}
- >
- {vulnerabilities.length === 1
- ? vulnerabilities[0].cve
- : `${vulnerabilities.length} vulnerabilities`}
-
-
-
- {tooltipText}
-
-
- >
- );
- },
- };
-};
-
-const generateTableHeaders = (
- router: InjectedRouter,
- isPremiumTier?: boolean,
- isSandboxMode?: boolean,
- teamId?: number
-): Column[] => {
- const softwareTableHeaders = [
- {
- title: "Name",
- Header: (cellProps: IHeaderProps): JSX.Element => (
-
- ),
- disableSortBy: false,
- accessor: "name",
- Cell: (cellProps: IStringCellProps): JSX.Element => {
- const { id, name, bundle_identifier: bundle } = cellProps.row.original;
-
- const onClickSoftware = (e: React.MouseEvent) => {
- // Allows for button to be clickable in a clickable row
- e.stopPropagation();
-
- router?.push(PATHS.SOFTWARE_DETAILS(id.toString()));
- };
-
- return (
-
- );
- },
- sortType: "caseInsensitive",
- },
- {
- title: "Version",
- Header: "Version",
- disableSortBy: true,
- accessor: "version",
- Cell: (cellProps: IStringCellProps): JSX.Element => (
-
- ),
- },
- {
- title: "Type",
- Header: "Type",
- disableSortBy: true,
- accessor: "source",
- Cell: (cellProps: IStringCellProps): JSX.Element => (
-
- ),
- },
- isPremiumTier
- ? generateEPSSColumnHeader(isSandboxMode)
- : generateVulnColumnHeader(),
- {
- title: "Hosts",
- Header: (cellProps: IHeaderProps): JSX.Element => (
-
- ),
- disableSortBy: false,
- accessor: "hosts_count",
- Cell: (cellProps: INumberCellProps): JSX.Element => (
-
-
-
-
-
-
-
-
- ),
- },
- ];
-
- return softwareTableHeaders;
-};
-
-export default generateTableHeaders;
diff --git a/frontend/pages/software/ManageSoftwarePage/_styles.scss b/frontend/pages/software/ManageSoftwarePage/_styles.scss
deleted file mode 100644
index ad2d32f106..0000000000
--- a/frontend/pages/software/ManageSoftwarePage/_styles.scss
+++ /dev/null
@@ -1,222 +0,0 @@
-.manage-software-page {
- &__header-wrap {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: 38px;
-
- .button-wrap {
- display: flex;
- justify-content: flex-end;
- min-width: 266px;
- }
- }
-
- &__manage-automations {
- padding: $pad-small $pad-medium;
- }
-
- &__count {
- display: flex;
- gap: 12px;
- }
-
- &__header {
- display: flex;
- align-items: center;
-
- .form-field {
- margin-bottom: 0;
- }
- }
-
- &__text {
- margin-right: $pad-large;
- }
-
- &__title {
- font-size: $large;
- }
-
- &__description {
- margin: 0;
- margin-bottom: $pad-large;
- max-width: 75%;
- @media (min-width: $break-md) {
- max-width: none;
- }
-
- h2 {
- text-transform: uppercase;
- color: $core-fleet-black;
- font-weight: $regular;
- font-size: $small;
- }
-
- p {
- color: $ui-fleet-black-75;
- margin: 0;
- font-size: $x-small;
- font-style: italic;
- }
- }
-
- &__table {
- .table-container {
- &__header {
- flex-direction: column-reverse; // Search bar on top
-
- @media (min-width: $break-md) {
- flex-direction: row;
- }
- }
-
- &__header-left {
- flex-direction: row; // Filter dropdown aligned with count
- .controls {
- .form-field--dropdown {
- margin: 0;
- }
- .manage-software-page__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
- }
- }
- }
- }
- &__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 {
- tr {
- .software-link {
- opacity: 0;
- transition: opacity 250ms;
- }
-
- &:hover {
- .software-link {
- opacity: 1;
- }
- }
- }
-
- thead {
- .name__header {
- width: $col-md;
- }
- .version__header {
- width: 0;
- }
- .vulnerabilities__header {
- display: none;
- width: 0;
- }
- .source__header {
- display: none;
- width: 0;
- }
- .hosts_count__header {
- width: auto;
- border-right: 0;
- }
- @media (min-width: $break-md) {
- .vulnerabilities__header {
- display: table-cell;
- }
- }
- @media (min-width: $break-lg) {
- .version__header {
- width: $col-md;
- }
- .source__header {
- display: table-cell;
- }
- }
- }
-
- tbody {
- .name__cell {
- max-width: $col-md;
- // Tooltip does not get cut off
- .children-wrapper {
- overflow: initial;
- }
- }
- .version__cell {
- width: 0;
- }
- .source__cell {
- display: none;
- width: 0;
- }
- .vulnerabilities__cell {
- display: none;
- width: 0;
- span {
- display: inline;
- }
- .text-muted {
- color: $ui-fleet-black-50;
- }
- }
- .hosts_count__cell {
- width: auto;
- .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-md) {
- .vulnerabilities__cell {
- display: table-cell;
- }
- }
- @media (min-width: $break-lg) {
- .version_cell {
- width: $col-md;
- }
- .source__cell {
- display: table-cell;
- }
- }
- }
- }
- }
- }
- }
- }
-}
diff --git a/frontend/pages/software/ManageSoftwarePage/index.ts b/frontend/pages/software/ManageSoftwarePage/index.ts
deleted file mode 100644
index a10fa5ef7a..0000000000
--- a/frontend/pages/software/ManageSoftwarePage/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./ManageSoftwarePage";
diff --git a/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx b/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx
deleted file mode 100644
index fb52cc715f..0000000000
--- a/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React, { useContext, useEffect } from "react";
-import { useErrorHandler } from "react-error-boundary";
-import { useQuery } from "react-query";
-import PATHS from "router/paths";
-
-import { AppContext } from "context/app";
-import {
- formatSoftwareType,
- ISoftware,
- IGetSoftwareByIdResponse,
-} from "interfaces/software";
-import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team";
-import softwareAPI from "services/entities/software";
-import hostCountAPI from "services/entities/host_count";
-
-import {
- DEFAULT_EMPTY_CELL_VALUE,
- DOCUMENT_TITLE_SUFFIX,
-} from "utilities/constants";
-import Spinner from "components/Spinner";
-import BackLink from "components/BackLink";
-import MainContent from "components/MainContent";
-import ViewAllHostsLink from "components/ViewAllHostsLink";
-import Vulnerabilities from "./components/Vulnerabilities";
-
-const baseClass = "software-details-page";
-
-interface ISoftwareDetailsProps {
- params: {
- software_id: string;
- };
-}
-
-const SoftwareDetailsPage = ({
- params: { software_id },
-}: ISoftwareDetailsProps): JSX.Element => {
- const {
- isPremiumTier,
- isSandboxMode,
- currentTeam,
- filteredSoftwarePath,
- } = useContext(AppContext);
-
- const handlePageError = useErrorHandler();
-
- const { data: software, isFetching: isFetchingSoftware } = useQuery<
- IGetSoftwareByIdResponse,
- Error,
- ISoftware
- >(
- ["softwareById", software_id],
- () => softwareAPI.getSoftwareById(software_id),
- {
- select: (data) => data.software,
- onError: (err) => handlePageError(err),
- }
- );
-
- const { data: hostCount } = useQuery<{ count: number }, Error, number>(
- ["hostCountBySoftwareId", software_id],
- () => hostCountAPI.load({ softwareId: parseInt(software_id, 10) }),
- { select: (data) => data.count }
- );
-
- const renderName = (sw: ISoftware) => {
- const { name, version } = sw;
- if (!name) {
- return "--";
- }
- if (!version) {
- return name;
- }
-
- return `${name}, ${version}`;
- };
-
- // Updates title that shows up on browser tabs
- useEffect(() => {
- // e.g., Software horizon, 5.2.0 details | Fleet for osquery
- document.title = `Software details | ${
- software && renderName(software)
- } | ${DOCUMENT_TITLE_SUFFIX}`;
- }, [location.pathname, software]);
-
- if (!software || isPremiumTier === undefined) {
- return ;
- }
-
- // Function instead of constant eliminates race condition with filteredSoftwarePath
- const backToSoftwarePath = () => {
- if (filteredSoftwarePath) {
- return filteredSoftwarePath;
- }
- return currentTeam && currentTeam?.id > APP_CONTEXT_NO_TEAM_ID
- ? `${PATHS.MANAGE_SOFTWARE}?team_id=${currentTeam?.id}`
- : PATHS.MANAGE_SOFTWARE;
- };
-
- return (
-
-
-
-
-
-
-
-
-
{renderName(software)}
-
-
-
-
-
-
-
-
- Type
-
- {formatSoftwareType(software.source)}
-
-
-
- Hosts
-
- {hostCount || DEFAULT_EMPTY_CELL_VALUE}
-
-
-
-
-
-
-
-
- );
-};
-
-export default SoftwareDetailsPage;
diff --git a/frontend/pages/software/SoftwareDetailsPage/_styles.scss b/frontend/pages/software/SoftwareDetailsPage/_styles.scss
deleted file mode 100644
index 3d8a569222..0000000000
--- a/frontend/pages/software/SoftwareDetailsPage/_styles.scss
+++ /dev/null
@@ -1,80 +0,0 @@
-.software-details-page {
- background-color: $ui-off-white;
-
- &__wrapper {
- display: grid;
- gap: $pad-medium;
- }
-
- .header {
- flex: 100%;
- display: flex;
- flex-direction: column;
- }
-
- .section {
- flex: 100%;
- display: flex;
- flex-direction: column;
- background-color: $core-white;
- border-radius: 16px;
- border: 1px solid $ui-fleet-black-10;
- padding: $pad-xxlarge;
- box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4);
-
- &__header {
- font-size: $medium;
- font-weight: $bold;
- margin: 0 0 $pad-large 0;
- }
-
- .info-flex {
- display: flex;
- flex-wrap: wrap;
-
- .info-flex__item--title {
- margin-bottom: 2.5rem;
- }
-
- &__item {
- font-size: $x-small;
- display: flex;
- flex-direction: column;
- white-space: nowrap;
-
- &--title {
- margin-right: $pad-xxlarge;
-
- .info-flex__data {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- }
- }
-
- &__header {
- color: $core-fleet-black;
- font-weight: $bold;
- }
- }
- }
-
- .title,
- .info {
- flex-direction: row;
- justify-content: space-between;
- margin: 0;
- padding-bottom: 0;
- }
-
- .name-container {
- display: flex;
- align-items: center;
- }
-
- .name {
- font-size: $large;
- font-weight: $bold;
- }
-}
diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tests.tsx b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tests.tsx
deleted file mode 100644
index bd98579dfe..0000000000
--- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tests.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import React from "react";
-import { render, screen } from "@testing-library/react";
-
-import createMockSoftware from "__mocks__/softwareMock";
-
-import Vulnerabilities from "./Vulnerabilities";
-
-describe("Vulnerabilities", () => {
- const [mockSoftwareWithVuln, mockSoftwareNoVulns] = [
- createMockSoftware({
- vulnerabilities: [
- {
- cve: "CVE_333",
- details_link: "https://its.really.bad",
- cvss_score: 9.5,
- epss_probability: 1,
- cisa_known_exploit: false,
- cve_published: "2023-02-14T20:15:00Z",
- },
- ],
- }),
- createMockSoftware(),
- ];
-
- it("renders the empty state when no vulnerabilities are provided", () => {
- render(
-
- );
-
- // Empty state
- expect(
- screen.getByText("No vulnerabilities detected for this software item.")
- ).toBeInTheDocument();
- expect(
- screen.getByText("Expecting to see vulnerabilities?")
- ).toBeInTheDocument();
- expect(screen.getByText("File an issue on GitHub")).toBeInTheDocument();
- });
-
- it("correctly renders a table when 1 vulnerability is provided, Premium tier", () => {
- render(
-
- );
-
- // Rendered table
- expect(screen.getByText("Vulnerability")).toBeInTheDocument();
- expect(screen.getByText("Probability of exploit")).toBeInTheDocument();
- expect(screen.getByText("Severity")).toBeInTheDocument();
- expect(screen.getByText("Known exploit")).toBeInTheDocument();
- expect(screen.getByText("Published")).toBeInTheDocument();
- expect(screen.getByText("CVE_333")).toBeInTheDocument();
- expect(screen.getByText("100%")).toBeInTheDocument();
- expect(screen.getByText("Critical", { exact: false })).toBeInTheDocument();
- expect(screen.getByText("ago", { exact: false })).toBeInTheDocument();
- });
-
- it("Only renders the 'Vulnerability' column when 1 vulnerability is provided on Free tier", () => {
- render(
-
- );
-
- // Rendered table
- expect(screen.getByText("Vulnerability")).toBeInTheDocument();
-
- // No premium-only columns
- expect(screen.queryByText("Probability of exploit")).toBeNull();
- expect(screen.queryByText("Severity")).toBeNull();
- expect(screen.queryByText("Known exploit")).toBeNull();
- expect(screen.queryByText("Published")).toBeNull();
-
- // Row data
- expect(screen.getByText("CVE_333")).toBeInTheDocument();
- expect(screen.queryByText("100%")).toBeNull();
- expect(screen.queryByText("Critical", { exact: false })).toBeNull();
- expect(screen.queryByText("ago", { exact: false })).toBeNull();
- });
-
- // Test for premium icons on column headers in Sandbox mode
- it("Renders 4 'Premium feature' tooltips when in premium tier Sandbox mode", () => {
- render(
-
- );
-
- expect(
- screen.getAllByText("This is a Fleet Premium feature.", { exact: false })
- ).toHaveLength(4);
- });
-});
diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx
deleted file mode 100644
index 7707d92f03..0000000000
--- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import React, { useMemo } from "react";
-
-import { ISoftware } from "interfaces/software";
-import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
-
-import TableContainer from "components/TableContainer";
-import CustomLink from "components/CustomLink";
-import EmptyTable from "components/EmptyTable";
-
-import generateVulnTableHeaders from "./VulnTableConfig";
-
-const baseClass = "vulnerabilities";
-
-interface IVulnerabilitiesProps {
- isLoading: boolean;
- isPremiumTier: boolean;
- isSandboxMode?: boolean;
- software: ISoftware;
-}
-
-const NoVulnsDetected = (): JSX.Element => {
- return (
-
- Expecting to see vulnerabilities?{" "}
-
- >
- }
- />
- );
-};
-
-const Vulnerabilities = ({
- isLoading,
- isPremiumTier,
- isSandboxMode = false,
- software,
-}: IVulnerabilitiesProps): JSX.Element => {
- const tableHeaders = useMemo(
- () => generateVulnTableHeaders(isPremiumTier, isSandboxMode),
- [isPremiumTier, isSandboxMode]
- );
-
- return (
-
-
Vulnerabilities
- {software?.vulnerabilities?.length ? (
- <>
- {software && (
-
- )}
- >
- ) : (
-
- )}
-
- );
-};
-export default Vulnerabilities;
diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/_styles.scss b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/_styles.scss
deleted file mode 100644
index a42e892e85..0000000000
--- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/_styles.scss
+++ /dev/null
@@ -1,60 +0,0 @@
-.software-details-page {
- &__hosts-link {
- display: flex;
- align-items: center;
- padding-bottom: $pad-small;
- height: 20px;
- font-size: $x-small;
- color: $core-vibrant-blue;
- font-weight: $bold;
- text-decoration: none;
- }
-
- #right-chevron {
- width: 16px;
- height: 16px;
- margin-left: $pad-small;
- }
-
- .section--vulnerabilities {
- .component__tooltip-wrapper__tip-text {
- max-width: $col-md;
- white-space: normal;
- }
-
- .data-table-block {
- .data-table__table {
- thead {
- .cve__header {
- width: $col-md;
- }
- .epss_probability__header {
- width: $col-sm;
- }
- .cvss_score__header {
- width: $col-sm;
- }
-
- @media (max-width: $tooltip-break-md) {
- .cisa_known_exploit__header {
- .component__tooltip-wrapper__tip-text {
- max-width: 200px; // Prevents horizontal scrolling off viewport
- white-space: normal;
- }
- }
- }
- }
-
- tr {
- .text-link {
- img {
- height: 12px;
- width: auto;
- padding-left: $pad-small;
- }
- }
- }
- }
- }
- }
-}
diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/index.ts b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/index.ts
deleted file mode 100644
index 184d8bd8da..0000000000
--- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./Vulnerabilities";
diff --git a/frontend/pages/software/SoftwareDetailsPage/index.ts b/frontend/pages/software/SoftwareDetailsPage/index.ts
deleted file mode 100644
index 0de53678fa..0000000000
--- a/frontend/pages/software/SoftwareDetailsPage/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./SoftwareDetailsPage";
diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx
index 60ecb6c752..0db0bc4537 100644
--- a/frontend/router/index.tsx
+++ b/frontend/router/index.tsx
@@ -29,7 +29,6 @@ import LabelPage from "pages/LabelPage";
import LoginPage, { LoginPreviewPage } from "pages/LoginPage";
import LogoutPage from "pages/LogoutPage";
import ManageHostsPage from "pages/hosts/ManageHostsPage";
-import ManageSoftwarePage from "pages/software/ManageSoftwarePage";
import ManageQueriesPage from "pages/queries/ManageQueriesPage";
import ManagePacksPage from "pages/packs/ManagePacksPage";
import ManagePoliciesPage from "pages/policies/ManagePoliciesPage";
@@ -43,7 +42,6 @@ import RegistrationPage from "pages/RegistrationPage";
import ResetPasswordPage from "pages/ResetPasswordPage";
import MDMAppleSSOPage from "pages/MDMAppleSSOPage";
import MDMAppleSSOCallbackPage from "pages/MDMAppleSSOCallbackPage";
-import SoftwareDetailsPage from "pages/software/SoftwareDetailsPage";
import ApiOnlyUser from "pages/ApiOnlyUser";
import Fleet403 from "pages/errors/Fleet403";
import Fleet404 from "pages/errors/Fleet404";
@@ -60,6 +58,11 @@ import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMd
import Scripts from "pages/ManageControlsPage/Scripts/Scripts";
import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage";
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 SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPage";
+import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage";
import PATHS from "router/paths";
@@ -212,9 +215,13 @@ const routes = (
-
-
-
+
+
+
+
+
+
+
diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts
index b9e6d586bb..0f429762b5 100644
--- a/frontend/router/paths.ts
+++ b/frontend/router/paths.ts
@@ -43,6 +43,16 @@ export default {
ADMIN_ORGANIZATION_ADVANCED: `${URL_PREFIX}/settings/organization/advanced`,
ADMIN_ORGANIZATION_FLEET_DESKTOP: `${URL_PREFIX}/settings/organization/fleet-desktop`,
+ // Software pages
+ SOFTWARE_TITLES: `${URL_PREFIX}/software/titles`,
+ SOFTWARE_VERSIONS: `${URL_PREFIX}/software/versions`,
+ SOFTWARE_TITLE_DETAILS: (id: string): string => {
+ return `${URL_PREFIX}/software/titles/${id}`;
+ },
+ SOFTWARE_VERSION_DETAILS: (id: string): string => {
+ return `${URL_PREFIX}/software/versions/${id}`;
+ },
+
EDIT_PACK: (packId: number): string => {
return `${URL_PREFIX}/packs/${packId}/edit`;
},
@@ -109,10 +119,7 @@ export default {
DEVICE_USER_DETAILS_POLICIES: (deviceAuthToken: string): string => {
return `${URL_PREFIX}/device/${deviceAuthToken}/policies`;
},
- MANAGE_SOFTWARE: `${URL_PREFIX}/software/manage`,
- SOFTWARE_DETAILS: (id: string): string => {
- return `${URL_PREFIX}/software/${id}`;
- },
+
TEAM_DETAILS_MEMBERS: (teamId?: number): string => {
if (teamId !== undefined && teamId > 0) {
return `${URL_PREFIX}/settings/teams/members?team_id=${teamId}`;
diff --git a/frontend/services/entities/host_count.ts b/frontend/services/entities/host_count.ts
index 7c30d17b35..fb04978c07 100644
--- a/frontend/services/entities/host_count.ts
+++ b/frontend/services/entities/host_count.ts
@@ -40,6 +40,8 @@ export interface IHostCountLoadOptions {
policyResponse?: string;
macSettingsStatus?: MacSettingsStatusQueryParam;
softwareId?: number;
+ softwareTitleId?: number;
+ softwareVersionId?: number;
lowDiskSpaceHosts?: number;
mdmId?: number;
mdmEnrollmentStatus?: string;
@@ -62,6 +64,8 @@ export default {
const globalFilter = options?.globalFilter || "";
const teamId = options?.teamId;
const softwareId = options?.softwareId;
+ const softwareTitleId = options?.softwareTitleId;
+ const softwareVersionId = options?.softwareVersionId;
const macSettingsStatus = options?.macSettingsStatus;
const status = options?.status;
const mdmId = options?.mdmId;
@@ -91,6 +95,8 @@ export default {
mdmEnrollmentStatus,
munkiIssueId,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
lowDiskSpaceHosts,
osName,
osId,
diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts
index 65f8b1b241..f9ebde9697 100644
--- a/frontend/services/entities/hosts.ts
+++ b/frontend/services/entities/hosts.ts
@@ -9,7 +9,7 @@ import {
reconcileMutuallyInclusiveHostParams,
} from "utilities/url";
import { SelectedPlatform } from "interfaces/platform";
-import { ISoftware } from "interfaces/software";
+import { ISoftwareTitle, ISoftware } from "interfaces/software";
import {
DiskEncryptionStatus,
BootstrapPackageStatus,
@@ -25,7 +25,8 @@ export interface ISortOption {
export interface ILoadHostsResponse {
hosts: IHost[];
- software: ISoftware;
+ software: ISoftware | undefined;
+ software_title: ISoftwareTitle | undefined;
munki_issue: IMunkiIssuesAggregate;
mobile_device_management_solution: IMdmSolution;
}
@@ -55,6 +56,8 @@ export interface ILoadHostsOptions {
policyResponse?: string;
macSettingsStatus?: MacSettingsStatusQueryParam;
softwareId?: number;
+ softwareTitleId?: number;
+ softwareVersionId?: number;
status?: HostStatus;
mdmId?: number;
mdmEnrollmentStatus?: string;
@@ -82,6 +85,8 @@ export interface IExportHostsOptions {
policyResponse?: string;
macSettingsStatus?: MacSettingsStatusQueryParam;
softwareId?: number;
+ softwareTitleId?: number;
+ softwareVersionId?: number;
status?: HostStatus;
mdmId?: number;
munkiIssueId?: number;
@@ -177,6 +182,8 @@ export default {
const policyId = options?.policyId;
const policyResponse = options?.policyResponse || "passing";
const softwareId = options?.softwareId;
+ const softwareTitleId = options?.softwareTitleId;
+ const softwareVersionId = options?.softwareVersionId;
const macSettingsStatus = options?.macSettingsStatus;
const status = options?.status;
const mdmId = options?.mdmId;
@@ -209,6 +216,8 @@ export default {
mdmEnrollmentStatus,
munkiIssueId,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
lowDiskSpaceHosts,
osSettings,
diskEncryptionStatus,
@@ -234,6 +243,8 @@ export default {
policyResponse = "passing",
macSettingsStatus,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@@ -273,6 +284,8 @@ export default {
mdmEnrollmentStatus,
munkiIssueId,
softwareId,
+ softwareTitleId,
+ softwareVersionId,
lowDiskSpaceHosts,
osId,
osName,
diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts
index 54f829171d..66bfd74161 100644
--- a/frontend/services/entities/software.ts
+++ b/frontend/services/entities/software.ts
@@ -6,10 +6,12 @@ import {
ISoftwareResponse,
ISoftwareCountResponse,
IGetSoftwareByIdResponse,
+ ISoftwareVersion,
+ ISoftwareTitle,
} from "interfaces/software";
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
-interface ISoftwareApiParams {
+export interface ISoftwareApiParams {
page?: number;
perPage?: number;
orderKey?: string;
@@ -19,6 +21,34 @@ interface ISoftwareApiParams {
teamId?: number;
}
+export interface ISoftwareTitlesResponse {
+ counts_updated_at: string | null;
+ count: number;
+ software_titles: ISoftwareTitle[];
+ meta: {
+ has_next_results: boolean;
+ has_previous_results: boolean;
+ };
+}
+
+export interface ISoftwareVersionsResponse {
+ counts_updated_at: string | null;
+ count: number;
+ software: ISoftwareVersion[];
+ meta: {
+ has_next_results: boolean;
+ has_previous_results: boolean;
+ };
+}
+
+export interface ISoftwareTitleResponse {
+ software_title: ISoftwareTitle;
+}
+
+export interface ISoftwareVersionResponse {
+ software: ISoftwareVersion;
+}
+
export interface ISoftwareQueryKey extends ISoftwareApiParams {
scope: "software";
}
@@ -103,4 +133,30 @@ export default {
return sendRequest("GET", path);
},
+
+ getSoftwareTitles: (params: ISoftwareApiParams) => {
+ const { SOFTWARE_TITLES } = endpoints;
+ const snakeCaseParams = convertParamsToSnakeCase(params);
+ const queryString = buildQueryStringFromParams(snakeCaseParams);
+ const path = `${SOFTWARE_TITLES}?${queryString}`;
+ return sendRequest("GET", path);
+ },
+
+ getSoftwareTitle: (id: number) => {
+ const { SOFTWARE_TITLE } = endpoints;
+ return sendRequest("GET", SOFTWARE_TITLE(id));
+ },
+
+ getSoftwareVersions: (params: ISoftwareApiParams) => {
+ const { SOFTWARE_VERSIONS } = endpoints;
+ const snakeCaseParams = convertParamsToSnakeCase(params);
+ const queryString = buildQueryStringFromParams(snakeCaseParams);
+ const path = `${SOFTWARE_VERSIONS}?${queryString}`;
+ return sendRequest("GET", path);
+ },
+
+ getSoftwareVersion: (id: number) => {
+ const { SOFTWARE_VERSION } = endpoints;
+ return sendRequest("GET", SOFTWARE_VERSION(id));
+ },
};
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index d0196ffba2..4e60187580 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -94,7 +94,15 @@ export default {
return `/${API_VERSION}/fleet/packs/${packId}/scheduled`;
},
SETUP: `/v1/setup`, // not a typo - hasn't been updated yet
+
+ // Software endpoints
SOFTWARE: `/${API_VERSION}/fleet/software`,
+ SOFTWARE_TITLES: `/${API_VERSION}/fleet/software/titles`,
+ SOFTWARE_TITLE: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}`,
+ SOFTWARE_VERSIONS: `/${API_VERSION}/fleet/software/versions`,
+ SOFTWARE_VERSION: (id: number) =>
+ `/${API_VERSION}/fleet/software/versions/${id}`,
+
SSO: `/v1/fleet/sso`,
STATUS_LABEL_COUNTS: `/${API_VERSION}/fleet/host_summary`,
STATUS_LIVE_QUERY: `/${API_VERSION}/fleet/status/live_query`,
@@ -129,7 +137,7 @@ export default {
USERS_ADMIN: `/${API_VERSION}/fleet/users/admin`,
VERSION: `/${API_VERSION}/fleet/version`,
- // SCRIPTS
+ // Script endpoints
HOST_SCRIPTS: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/scripts`,
SCRIPTS: `/${API_VERSION}/fleet/scripts`,
SCRIPT: (id: number) => `/${API_VERSION}/fleet/scripts/${id}`,
diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts
index 5fcf792bbc..0f4c9a408f 100644
--- a/frontend/utilities/url/index.ts
+++ b/frontend/utilities/url/index.ts
@@ -30,6 +30,8 @@ interface IMutuallyExclusiveHostParams {
munkiIssueId?: number;
lowDiskSpaceHosts?: number;
softwareId?: number;
+ softwareVersionId?: number;
+ softwareTitleId?: number;
osId?: number;
osName?: string;
osVersion?: string;
@@ -100,6 +102,8 @@ export const reconcileMutuallyExclusiveHostParams = ({
munkiIssueId,
lowDiskSpaceHosts,
softwareId,
+ softwareVersionId,
+ softwareTitleId,
osId,
osName,
osVersion,
@@ -131,6 +135,10 @@ export const reconcileMutuallyExclusiveHostParams = ({
return { mdm_enrollment_status: mdmEnrollmentStatus };
case !!munkiIssueId:
return { munki_issue_id: munkiIssueId };
+ case !!softwareTitleId:
+ return { software_title_id: softwareTitleId };
+ case !!softwareVersionId:
+ return { software_version_id: softwareVersionId };
case !!softwareId:
return { software_id: softwareId };
case !!osId:
@@ -141,7 +149,6 @@ export const reconcileMutuallyExclusiveHostParams = ({
return { low_disk_space: lowDiskSpaceHosts };
case !!osSettings:
return { [HOSTS_QUERY_PARAMS.OS_SETTINGS]: osSettings };
-
case !!diskEncryptionStatus:
return { [HOSTS_QUERY_PARAMS.DISK_ENCRYPTION]: diskEncryptionStatus };
case !!bootstrapPackageStatus:
diff --git a/scripts/mdm/linux/linux-change-password.sh b/scripts/mdm/linux/linux-change-password.sh
new file mode 100644
index 0000000000..dfe4abf0b2
--- /dev/null
+++ b/scripts/mdm/linux/linux-change-password.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+# Disable automatic login for common display managers
+disable_autologin() {
+ # GDM (GNOME Display Manager)
+ if [ -f /etc/gdm3/custom.conf ]; then
+ sed -i '/^AutomaticLoginEnable/s/^/#/' /etc/gdm3/custom.conf
+ sed -i '/^AutomaticLogin/s/^/#/' /etc/gdm3/custom.conf
+ fi
+
+ # LightDM
+ if [ -f /etc/lightdm/lightdm.conf ]; then
+ sed -i '/^autologin-user=/s/^/#/' /etc/lightdm/lightdm.conf
+ fi
+
+ # Add similar cases for other display managers if needed
+}
+
+# Disable automatic login
+disable_autologin
+
+# Loop through all users in /etc/passwd
+awk -F':' '{ if ($3 >= 1000 && $3 < 60000) print $1 }' /etc/passwd | while read user
+do
+ if [ "$user" != "root" ]; then
+ echo "Logging out $user"
+ pkill -KILL -u "$user" # Kill user processes. This will log out logged-in users.
+ password=$(openssl rand -base64 9)
+ echo "$user:$password" | chpasswd
+ echo "$user: new password is $password"
+ fi
+done
+
+echo "All non-root users have been logged out and their passwords changed."
diff --git a/scripts/mdm/linux/linux-lock.sh b/scripts/mdm/linux/linux-lock.sh
new file mode 100644
index 0000000000..6c7e317240
--- /dev/null
+++ b/scripts/mdm/linux/linux-lock.sh
@@ -0,0 +1,32 @@
+#!/bin/sh
+
+# Disable automatic login for common display managers
+disable_autologin() {
+ # GDM (GNOME Display Manager)
+ if [ -f /etc/gdm3/custom.conf ]; then
+ sed -i '/^AutomaticLoginEnable/s/^/#/' /etc/gdm3/custom.conf
+ sed -i '/^AutomaticLogin/s/^/#/' /etc/gdm3/custom.conf
+ fi
+
+ # LightDM
+ if [ -f /etc/lightdm/lightdm.conf ]; then
+ sed -i '/^autologin-user=/s/^/#/' /etc/lightdm/lightdm.conf
+ fi
+
+ # Add similar cases for other display managers if needed
+}
+
+# Disable automatic login
+disable_autologin
+
+# Loop through all users in /etc/passwd
+awk -F':' '{ if ($3 >= 1000 && $3 < 60000) print $1 }' /etc/passwd | while read user
+do
+ if [ "$user" != "root" ]; then
+ echo "Logging out $user"
+ pkill -KILL -u "$user" # Kill user processes. This will log out logged-in users.
+ passwd -l "$user" # Lock the user account
+ fi
+done
+
+echo "All non-root users have been logged out and their accounts locked."
diff --git a/scripts/mdm/linux/linux-unlock.sh b/scripts/mdm/linux/linux-unlock.sh
new file mode 100644
index 0000000000..2122fb837b
--- /dev/null
+++ b/scripts/mdm/linux/linux-unlock.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+# Unlock password for all non-root users
+awk -F':' '{ if ($3 >= 1000 && $3 < 60000) print $1 }' /etc/passwd | while read user
+do
+ echo "$user"
+ if [ "$user" != "root" ]; then
+ echo "Unlocking password for $user"
+ passwd -u $user
+ fi
+done
diff --git a/scripts/mdm/linux/linux-wipe.sh b/scripts/mdm/linux/linux-wipe.sh
new file mode 100644
index 0000000000..69a78b1235
--- /dev/null
+++ b/scripts/mdm/linux/linux-wipe.sh
@@ -0,0 +1,46 @@
+#!/bin/sh
+
+# Function to log out all users and lock their passwords except root
+logout_users() {
+ for user in $(who | awk '{print $1}' | sort | uniq)
+ do
+ if [ "$user" != "root" ]; then
+ echo "Logging out $user"
+ pkill -KILL -u "$user"
+ passwd -l "$user"
+ fi
+ done
+}
+
+# Function to wipe non-essential data
+wipe_non_essential_data() {
+ # Define non-essential paths
+ non_essential_paths="/home/* /tmp /var/tmp /var/log /home/*/.cache /var/cache /home/*/.local/share/Trash"
+
+ for path in $non_essential_paths
+ do
+ if [ -e "$path" ]; then
+ echo "Wiping $path"
+ rm -rf "$path"
+ fi
+ done
+}
+
+# Function to wipe system files - Warning: This will render the system inoperable
+wipe_system_files() {
+ # Define essential system paths
+ essential_system_paths="/bin /sbin /usr /lib"
+
+ for path in $essential_system_paths
+ do
+ echo "Wiping $path"
+ rm -rf "$path"
+ done
+}
+
+# Start the wiping process
+logout_users
+wipe_non_essential_data
+wipe_system_files
+
+echo "Wiping process completed."
diff --git a/scripts/mdm/windows/windows-change-password.ps1 b/scripts/mdm/windows/windows-change-password.ps1
new file mode 100644
index 0000000000..43cca1128e
--- /dev/null
+++ b/scripts/mdm/windows/windows-change-password.ps1
@@ -0,0 +1,52 @@
+# PowerShell script to log off all users and change their passwords
+
+# Function to generate a random password
+function Generate-Password {
+ param (
+ [int]$length = 12
+ )
+ $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_-+=<>?/"
+ $password = -join ((1..$length) | ForEach-Object { Get-Random -Maximum $chars.length } | ForEach-Object { $chars[$_]} )
+ return $password
+}
+
+# Log off all non-administrative users
+$loggedOffUsers = @{}
+Get-WmiObject -Class Win32_UserProfile | Where-Object { $_.Special -eq $false } | ForEach-Object {
+ $username = $_.LocalPath.Split('\')[-1]
+ if ($username -ne "Administrator" -and $username -ne $env:USERNAME -and -not $loggedOffUsers.ContainsKey($username)) {
+ try {
+ $userSessions = query user | Where-Object { $_ -match "\b$username\b" }
+ foreach ($session in $userSessions) {
+ if ($session -match "\s+(\d+)\s+Disc\s+") {
+ # Disconnected sessions can't be logged off
+ continue
+ }
+ elseif ($session -match "\s+(\d+)\s+") {
+ $sessionID = $matches[1]
+ logoff $sessionID
+ $loggedOffUsers[$username] = $true
+ Write-Host "Logged out user: $username"
+ }
+ }
+ } catch {
+ Write-Host "Could not log off user: $username. Error: $($_.Exception.Message)"
+ }
+ }
+}
+
+# Get all local user accounts except built-in accounts like 'Administrator'
+$users = Get-LocalUser | Where-Object { $_.Name -notlike "Administrator" -and $_.PrincipalSource -eq "Local" }
+
+# Change password for each user and output the new password
+foreach ($user in $users) {
+ $newPassword = Generate-Password -length 12
+ $securePassword = ConvertTo-SecureString $newPassword -AsPlainText -Force
+
+ try {
+ Set-LocalUser -Name $user.Name -Password $securePassword
+ Write-Host "Password for user $($user.Name) changed successfully. New Password: $newPassword"
+ } catch {
+ Write-Host "Failed to change password for user $($user.Name)"
+ }
+}
diff --git a/scripts/mdm/windows/windows-disable-administrator.ps1 b/scripts/mdm/windows/windows-disable-administrator.ps1
new file mode 100644
index 0000000000..de66080e7b
--- /dev/null
+++ b/scripts/mdm/windows/windows-disable-administrator.ps1
@@ -0,0 +1,8 @@
+# PowerShell script to disable the Administrator account
+
+# Run this script as an administrator
+
+# Disable the Administrator account
+Disable-LocalUser -Name "Administrator"
+
+Write-Host "Administrator account has been disabled."
diff --git a/scripts/mdm/windows/windows-enable-administrator.ps1 b/scripts/mdm/windows/windows-enable-administrator.ps1
new file mode 100644
index 0000000000..13c48b04fe
--- /dev/null
+++ b/scripts/mdm/windows/windows-enable-administrator.ps1
@@ -0,0 +1,29 @@
+# PowerShell script to enable the Administrator account and set a random, secure password
+
+# Run this script as an administrator
+
+# Function to generate a random password
+function Generate-Password {
+ param (
+ [int]$length = 12
+ )
+ $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_-+=<>?/"
+ $password = -join ((1..$length) | ForEach-Object { Get-Random -Maximum $chars.length } | ForEach-Object { $chars[$_]} )
+ return $password
+}
+
+# Generate a random password
+$password = Generate-Password -length 12
+
+# Convert the password to a SecureString
+$securePassword = ConvertTo-SecureString $password -AsPlainText -Force
+
+# Enable the Administrator account
+Enable-LocalUser -Name "Administrator"
+
+# Set the generated password for the Administrator account
+Set-LocalUser -Name "Administrator" -Password $securePassword
+
+# Output the password
+Write-Host "Administrator account has been enabled."
+Write-Host "Generated Password: $password"
diff --git a/scripts/mdm/windows/windows-lock.ps1 b/scripts/mdm/windows/windows-lock.ps1
new file mode 100644
index 0000000000..e4d9809fee
--- /dev/null
+++ b/scripts/mdm/windows/windows-lock.ps1
@@ -0,0 +1,35 @@
+# PowerShell script to log off all non-administrative users and disable their accounts
+
+# Log off all non-administrative users
+$loggedOffUsers = @{}
+Get-WmiObject -Class Win32_UserProfile | Where-Object { $_.Special -eq $false } | ForEach-Object {
+ $username = $_.LocalPath.Split('\')[-1]
+ if ($username -ne "Administrator" -and $username -ne $env:USERNAME -and -not $loggedOffUsers.ContainsKey($username)) {
+ try {
+ $userSessions = query user | Where-Object { $_ -match "\b$username\b" }
+ foreach ($session in $userSessions) {
+ if ($session -match "\s+(\d+)\s+Disc\s+") {
+ # Disconnected sessions can't be logged off
+ continue
+ }
+ elseif ($session -match "\s+(\d+)\s+") {
+ $sessionID = $matches[1]
+ logoff $sessionID
+ $loggedOffUsers[$username] = $true
+ Write-Host "Logged out user: $username"
+ }
+ }
+ } catch {
+ Write-Host "Could not log off user: $username. Error: $($_.Exception.Message)"
+ }
+ }
+}
+
+# Disable all non-administrative local user accounts
+Get-LocalUser | Where-Object { $_.Enabled -eq $true -and $_.Name -ne "Administrator" } | ForEach-Object {
+ $username = $_.Name
+ Disable-LocalUser -Name $username
+ Write-Host "Disabled account for $username"
+}
+
+Write-Host "All non-administrative users have been logged out and their accounts disabled."
diff --git a/scripts/mdm/windows/windows-unlock.ps1 b/scripts/mdm/windows/windows-unlock.ps1
new file mode 100644
index 0000000000..6a10c00fb3
--- /dev/null
+++ b/scripts/mdm/windows/windows-unlock.ps1
@@ -0,0 +1,14 @@
+# PowerShell script to enable all disabled local user accounts
+
+# Get all local user accounts
+$localUsers = Get-LocalUser
+
+# Enable each disabled user account
+foreach ($user in $localUsers) {
+ if ($user.Enabled -eq $false) {
+ Enable-LocalUser -Name $user.Name
+ Write-Host "Enabled user account: $($user.Name)"
+ }
+}
+
+Write-Host "All disabled user accounts have been enabled."
diff --git a/server/fleet/software.go b/server/fleet/software.go
index 089ea808f6..da30d346e6 100644
--- a/server/fleet/software.go
+++ b/server/fleet/software.go
@@ -120,7 +120,7 @@ type SoftwareVersion struct {
// Version is the version string we grab for this specific software.
Version string `db:"version" json:"version"`
// Vulnerabilities is the list of CVE names for vulnerabilities found for this version.
- Vulnerabilities *SliceString `db:"vulnerabilities" json:"vulnerabilities,omitempty"`
+ Vulnerabilities *SliceString `db:"vulnerabilities" json:"vulnerabilities"`
// HostsCount is the number of hosts that use this software version.
HostsCount *uint `db:"hosts_count" json:"hosts_count,omitempty"`
diff --git a/website/config/routes.js b/website/config/routes.js
index a90dbf80c0..7209aab9e1 100644
--- a/website/config/routes.js
+++ b/website/config/routes.js
@@ -511,6 +511,7 @@ module.exports.routes = {
// These are external links not maintained by Fleet. We can point the Fleet UI to redirects here instead of the
// original sources to help avoid broken links.
'GET /learn-more-about/chromeos-updates': 'https://support.google.com/chrome/a/answer/6220366',
+ 'GET /sign-in-to/microsoft-automatic-enrollment-tool': 'https://portal.azure.com',
// Sitemap
// =============================================================================================================