diff --git a/articles/enforce-disk-encryption.md b/articles/enforce-disk-encryption.md index c178b2fa87..fbd6c4e6f9 100644 --- a/articles/enforce-disk-encryption.md +++ b/articles/enforce-disk-encryption.md @@ -74,6 +74,36 @@ How to view the disk encryption key: > The disk encryption key is deleted if a host is transferred to a team with disk encryption turned off. To re-escrow they key, transfer the host back to a team with disk encryption on. +## Use disk encryption key to login + +Disk encryption keys are used to login to workstations (hosts) when the end user forgets their password or when the host is returned to the organization after an end user leaves. + +### macOS + +1. With the macOS host in front of you, restart the host and select the end user's account. + +2. Select the question mark icon **(?)** next to the password field and select **Restart and show password reset options**. If you don't see the **(?)** icon, try entering any incorrect password several times. + +3. Follow the instructions on the Mac to enter the disk encryption (recovery) key. + +### Linux + +1. With the Linux host in front of you, restart it. + +2. When prompted to unlock the disk, enter the disk encryption key. + +3. On the **Host details** page in Fleet, find the local user's username in the **Users** table. + +4. Next, add the following script to Fleet (deletes the local password (passphrase)): + +``` +passwd -d +``` + +5. Head back to the **Host details** page and select **Actions > Run script** to run the script. + +#### + ## Migrate macOS hosts When migrating macOS hosts from another MDM solution, in order to complete the process of encrypting the hard drive and escrowing the key in Fleet, your end users must log out or restart their Mac. diff --git a/changes/26136-user-table-cleanup b/changes/26136-user-table-cleanup new file mode 100644 index 0000000000..74824d55b1 --- /dev/null +++ b/changes/26136-user-table-cleanup @@ -0,0 +1 @@ +- Fleet UI: Constistent behavior for table overflow and not hiding badges when user names overflow table cell diff --git a/changes/26569-team-id-dropping-bug b/changes/26569-team-id-dropping-bug new file mode 100644 index 0000000000..378a8ff17b --- /dev/null +++ b/changes/26569-team-id-dropping-bug @@ -0,0 +1 @@ +- Fleet UI: Fixed several links that were dropping team_id parameters resetting team to All teams diff --git a/changes/issue-26209-android-mdm-acitivites b/changes/issue-26209-android-mdm-acitivites new file mode 100644 index 0000000000..7c5da573cc --- /dev/null +++ b/changes/issue-26209-android-mdm-acitivites @@ -0,0 +1 @@ +- add android mdm activities diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index a98ebd05ec..b50e6ba0bd 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -1234,6 +1234,15 @@ the way that the Fleet server works. } req.Body = http.MaxBytesReader(rw, req.Body, fleet.MaxSoftwareInstallerSize) } + + if req.Method == http.MethodGet && strings.HasSuffix(req.URL.Path, "/fleet/android_enterprise/signup_sse") { + // When enabling Android MDM, frontend UI will wait for the admin to finish the setup in Google. + rc := http.NewResponseController(rw) + if err := rc.SetWriteDeadline(time.Now().Add(30 * time.Minute)); err != nil { + level.Error(logger).Log("msg", "http middleware failed to override endpoint write timeout", "err", err) + } + } + apiHandler.ServeHTTP(rw, req) }) diff --git a/docs/Contributing/Understanding-host-vitals.md b/docs/Contributing/Understanding-host-vitals.md index 42531f793d..32a4357b1e 100644 --- a/docs/Contributing/Understanding-host-vitals.md +++ b/docs/Contributing/Understanding-host-vitals.md @@ -584,19 +584,7 @@ SELECT '' AS vendor, '' AS arch, path AS installed_path -FROM cached_users CROSS JOIN firefox_addons USING (uid) -UNION -SELECT - name AS name, - version AS version, - '' AS extension_id, - '' AS browser, - 'python_packages' AS source, - '' AS release, - '' AS vendor, - '' AS arch, - path AS installed_path -FROM python_packages; +FROM cached_users CROSS JOIN firefox_addons USING (uid); ``` ## software_macos @@ -621,18 +609,6 @@ SELECT path AS installed_path FROM apps UNION -SELECT - name AS name, - version AS version, - '' AS bundle_identifier, - '' AS extension_id, - '' AS browser, - 'python_packages' AS source, - '' AS vendor, - 0 AS last_opened_at, - path AS installed_path -FROM python_packages -UNION SELECT name AS name, version AS version, @@ -757,6 +733,58 @@ WITH app_paths AS ( WHERE apps.bundle_identifier = 'org.mozilla.firefox' ``` +## software_python_packages + +- Description: Prior to osquery version 5.16.0, the python_packages table did not search user directories. + +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows + +- Discovery query: +```sql +SELECT 1 FROM osquery_info WHERE version_compare(version, '5.16.0') < 0 +``` + +- Query: +```sql +SELECT + name AS name, + version AS version, + '' AS extension_id, + '' AS browser, + 'python_packages' AS source, + '' AS vendor, + path AS installed_path + FROM python_packages +``` + +## software_python_packages_with_users_dir + +- Description: As of osquery version 5.16.0, the python_packages table searches user directories with support from a cross join on users. See https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table. + +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows + +- Discovery query: +```sql +SELECT 1 FROM osquery_info WHERE version_compare(version, '5.16.0') >= 0 +``` + +- Query: +```sql +WITH cached_users AS (WITH cached_groups AS (select * from groups) + SELECT uid, username, type, groupname, shell + FROM users LEFT JOIN cached_groups USING (gid) + WHERE type <> 'special' AND shell NOT LIKE '%/false' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/shutdown' AND shell NOT LIKE '%/halt' AND username NOT LIKE '%$' AND username NOT LIKE '\_%' ESCAPE '\' AND NOT (username = 'sync' AND shell ='/bin/sync' AND directory <> '')) + SELECT + name AS name, + version AS version, + '' AS extension_id, + '' AS browser, + 'python_packages' AS source, + '' AS vendor, + path AS installed_path + FROM cached_users CROSS JOIN python_packages USING (uid) +``` + ## software_vscode_extensions - Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows @@ -805,16 +833,6 @@ SELECT install_location AS installed_path FROM programs UNION -SELECT - name AS name, - version AS version, - '' AS extension_id, - '' AS browser, - 'python_packages' AS source, - '' AS vendor, - path AS installed_path -FROM python_packages -UNION SELECT name AS name, version AS version, diff --git a/docs/Deploy/deploy-fleet.md b/docs/Deploy/deploy-fleet.md index bbf8f0e15c..a4e4c9444f 100644 --- a/docs/Deploy/deploy-fleet.md +++ b/docs/Deploy/deploy-fleet.md @@ -81,7 +81,7 @@ This workflow takes about 30 minutes to complete and supports between 10 and 350 ### Instructions -1. [Download](https://github.com/fleetdm/fleet-terraform/tree/mainexample/main.tf) the Fleet `main.tf` Terraform file. +1. [Download](https://github.com/fleetdm/fleet-terraform/blob/main/example/main.tf) the Fleet `main.tf` Terraform file. 2. Edit the following variables in the `main.tf` Terraform file you just downloaded to match your environment: diff --git a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx index dbacef6bc8..e53ce0e068 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx @@ -13,10 +13,11 @@ import RevealButton from "components/buttons/RevealButton"; // @ts-ignore import InputField from "components/forms/fields/InputField"; import TooltipWrapper from "components/TooltipWrapper"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; import InfoBanner from "components/InfoBanner/InfoBanner"; import CustomLink from "components/CustomLink/CustomLink"; import Radio from "components/forms/fields/Radio"; +import TabText from "components/TabText"; import { isValidPemCertificate } from "../../../pages/hosts/ManageHostsPage/helpers"; import IosIpadosPanel from "./IosIpadosPanel"; @@ -573,7 +574,7 @@ const PlatformWrapper = ({ return (
- + setSelectedTabIndex(index)} selectedIndex={selectedTabIndex} @@ -584,7 +585,7 @@ const PlatformWrapper = ({ // so we add a hidden pseudo element with the same text string return ( - {navItem.name} + {navItem.name} ); })} @@ -601,7 +602,7 @@ const PlatformWrapper = ({ ); })} - +
- ); -}; - const SelectTargets = ({ baseClass, queryId, diff --git a/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.stories.tsx b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.stories.tsx new file mode 100644 index 0000000000..f5ca496d71 --- /dev/null +++ b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.stories.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { ISelectLabel, ISelectTeam } from "interfaces/target"; +import TargetChipSelector from "./TargetChipSelector"; // Adjust the path if necessary + +const meta: Meta = { + component: TargetChipSelector, + title: "Components/TargetChipSelector", + argTypes: { + entity: { + description: "The label or team entity to display.", + control: { type: "object" }, + }, + isSelected: { + description: + "Whether the chip is currently selected, updated by parent onClick handler.", + control: { type: "boolean" }, + }, + onClick: { + description: "The handler to call when the chip is clicked.", + action: "clicked", // Use Storybook's action to track clicks + }, + }, + parameters: { + backgrounds: { + default: "light", + values: [ + { name: "light", value: "#ffffff" }, + { name: "dark", value: "#333333" }, + ], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// Example data for labels and teams +const mockLabel: ISelectLabel = { + id: 1, + name: "Example Label", + label_type: "regular", + description: "A test label", +}; + +const mockTeam: ISelectTeam = { + id: 2, + name: "Example Team", + description: "A test team", +}; + +export const LabelExample: Story = { + args: { + entity: mockLabel, + isSelected: false, + onClick: (value) => (event) => { + event.preventDefault(); + console.log("Clicked label:", value); + }, + }, + render: (args) => ( + + ), +}; + +export const TeamExample: Story = { + args: { + entity: mockTeam, + isSelected: true, + onClick: (value) => (event) => { + event.preventDefault(); + console.log("Clicked team:", value); + }, + }, + render: (args) => ( + + ), +}; + +export const BuiltInLabelExample: Story = { + args: { + entity: { + id: 3, + name: "MS Windows", + label_type: "builtin", + description: "Microsoft Windows hosts", + }, + isSelected: false, + onClick: (value) => (event) => { + event.preventDefault(); + console.log("Clicked label:", value); + }, + }, + render: (args) => ( + + ), +}; diff --git a/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tests.tsx b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tests.tsx new file mode 100644 index 0000000000..6259fa6490 --- /dev/null +++ b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tests.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { ISelectLabel, ISelectTeam } from "interfaces/target"; +import TargetChipSelector from "./TargetChipSelector"; + +describe("TargetChipSelector", () => { + const mockOnClick = jest.fn(); + + const mockLabel: ISelectLabel = { + id: 1, + name: "Example Label", + label_type: "regular", + description: "A test label", + }; + + const mockTeam: ISelectTeam = { + id: 2, + name: "Example Team", + description: "A test team", + }; + + it("renders the correct display text for a label", () => { + render( + + ); + + expect(screen.getByText("Example Label")).toBeInTheDocument(); + }); + + it("renders the correct display text for a team", () => { + render( + + ); + + expect(screen.getByText("Example Team")).toBeInTheDocument(); + }); + + it("renders the correct icon when selected", () => { + render( + + ); + + expect(screen.getByLabelText("check")).toBeInTheDocument(); + }); + + it("renders the correct icon when not selected", () => { + render( + + ); + + expect(screen.getByLabelText("plus")).toBeInTheDocument(); + }); + + it("calls the onClick handler with the correct entity when clicked", () => { + render( + (event) => mockOnClick(value, event)} + /> + ); + + fireEvent.click(screen.getByRole("button")); + + expect(mockOnClick).toHaveBeenCalledWith(mockLabel, expect.any(Object)); + }); + + it("applies the correct data-selected attribute when selected", () => { + render( + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-selected", "true"); + }); + + it("applies the correct data-selected attribute when not selected", () => { + render( + + ); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("data-selected", "false"); + }); +}); diff --git a/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tsx b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tsx new file mode 100644 index 0000000000..98ad2ccc1e --- /dev/null +++ b/frontend/components/LiveQuery/TargetChipSelector/TargetChipSelector.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { + ISelectLabel, + ISelectTeam, + ISelectTargetsEntity, +} from "interfaces/target"; +import Icon from "components/Icon"; +import { + PlatformLabelNameFromAPI, + LABEL_DISPLAY_MAP, +} from "utilities/constants"; + +interface ITargetChipSelectorProps { + entity: ISelectLabel | ISelectTeam; + isSelected: boolean; + onClick: ( + value: ISelectLabel | ISelectTeam + ) => React.MouseEventHandler; +} + +const isBuiltInLabel = ( + entity: ISelectTargetsEntity +): entity is ISelectLabel & { label_type: "builtin" } => { + return "label_type" in entity && entity.label_type === "builtin"; +}; + +const TargetChipSelector = ({ + entity, + isSelected, + onClick, +}: ITargetChipSelectorProps): JSX.Element => { + const displayText = (): string => { + if (isBuiltInLabel(entity)) { + const labelName = entity.name as PlatformLabelNameFromAPI; + if (labelName in LABEL_DISPLAY_MAP) { + return LABEL_DISPLAY_MAP[labelName] || labelName; + } + } + + return entity.name || "Missing display name"; + }; + + return ( + + ); +}; + +export default TargetChipSelector; diff --git a/frontend/components/LiveQuery/_styles.scss b/frontend/components/LiveQuery/TargetChipSelector/_styles.scss similarity index 92% rename from frontend/components/LiveQuery/_styles.scss rename to frontend/components/LiveQuery/TargetChipSelector/_styles.scss index e6e9ce0554..ca844e1354 100644 --- a/frontend/components/LiveQuery/_styles.scss +++ b/frontend/components/LiveQuery/TargetChipSelector/_styles.scss @@ -1,4 +1,4 @@ -.target-pill-selector { +.target-chip-selector { padding: $pad-small; background-color: $core-white; border: none; @@ -34,7 +34,7 @@ } &:hover { - box-shadow: inset 0 0 0 1px $core-vibrant-blue-over; + background-color: $ui-vibrant-blue-10; } &:active { diff --git a/frontend/components/LiveQuery/TargetChipSelector/index.ts b/frontend/components/LiveQuery/TargetChipSelector/index.ts new file mode 100644 index 0000000000..94cc8dca7f --- /dev/null +++ b/frontend/components/LiveQuery/TargetChipSelector/index.ts @@ -0,0 +1 @@ +export { default } from "./TargetChipSelector"; diff --git a/frontend/components/MDM/AppleBMRenewalMessage/AppleBMRenewalMessage.tsx b/frontend/components/MDM/AppleBMRenewalMessage/AppleBMRenewalMessage.tsx index c64031d51f..d8a6a7cf15 100644 --- a/frontend/components/MDM/AppleBMRenewalMessage/AppleBMRenewalMessage.tsx +++ b/frontend/components/MDM/AppleBMRenewalMessage/AppleBMRenewalMessage.tsx @@ -19,8 +19,7 @@ const AppleBMRenewalMessage = ({ expired }: IAppleBMRenewalMessageProps) => { url="/settings/integrations/mdm/abm" text="Renew ABM" className={`${baseClass}`} - color="core-fleet-black" - iconColor="core-fleet-black" + variant="banner-link" /> } > diff --git a/frontend/components/MDM/AppleBMTermsMessage/AppleBMTermsMessage.tsx b/frontend/components/MDM/AppleBMTermsMessage/AppleBMTermsMessage.tsx index 6fcff4be67..ce59db8432 100644 --- a/frontend/components/MDM/AppleBMTermsMessage/AppleBMTermsMessage.tsx +++ b/frontend/components/MDM/AppleBMTermsMessage/AppleBMTermsMessage.tsx @@ -16,8 +16,7 @@ const AppleBMTermsMessage = () => { text="Go to ABM" className={`${baseClass}__new-tab`} newTab - color="core-fleet-black" - iconColor="core-fleet-black" + variant="banner-link" /> } > diff --git a/frontend/components/MDM/ApplePNCertRenewalMessage/ApplePNCertRenewalMessage.tsx b/frontend/components/MDM/ApplePNCertRenewalMessage/ApplePNCertRenewalMessage.tsx index 824468ea61..7e790f4d96 100644 --- a/frontend/components/MDM/ApplePNCertRenewalMessage/ApplePNCertRenewalMessage.tsx +++ b/frontend/components/MDM/ApplePNCertRenewalMessage/ApplePNCertRenewalMessage.tsx @@ -20,8 +20,7 @@ const ApplePNCertRenewalMessage = ({ expired }: IApplePNCertRenewalMessage) => { text="Renew APNs" className={`${baseClass}__new-tab`} newTab - color="core-fleet-black" - iconColor="core-fleet-black" + variant="banner-link" /> } > diff --git a/frontend/components/MainContent/banners/VppRenewalMessage/VppRenewalMessage.tsx b/frontend/components/MainContent/banners/VppRenewalMessage/VppRenewalMessage.tsx index 8980f485f2..b4498002df 100644 --- a/frontend/components/MainContent/banners/VppRenewalMessage/VppRenewalMessage.tsx +++ b/frontend/components/MainContent/banners/VppRenewalMessage/VppRenewalMessage.tsx @@ -19,8 +19,7 @@ const VppRenewalMessage = ({ expired }: IVppRenewalMessageProps) => { url="/settings/integrations/mdm/vpp" text="Renew VPP" className={`${baseClass}`} - color="core-fleet-black" - iconColor="core-fleet-black" + variant="banner-link" /> } > diff --git a/frontend/components/PlatformSelector/PlatformSelector.tsx b/frontend/components/PlatformSelector/PlatformSelector.tsx index 54bbfbcd82..a9955c6a68 100644 --- a/frontend/components/PlatformSelector/PlatformSelector.tsx +++ b/frontend/components/PlatformSelector/PlatformSelector.tsx @@ -5,7 +5,7 @@ import { IPolicySoftwareToInstall } from "interfaces/policy"; import Checkbox from "components/forms/fields/Checkbox"; import CustomLink from "components/CustomLink"; import TooltipWrapper from "components/TooltipWrapper"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import paths from "router/paths"; interface IPlatformSelectorProps { @@ -49,9 +49,10 @@ export const PlatformSelector = ({ } const softwareName = installSoftware.name; const softwareId = installSoftware.software_title_id.toString(); - const softwareLink = `${paths.SOFTWARE_TITLE_DETAILS( - softwareId - )}?${buildQueryStringFromParams({ team_id: currentTeamId })}`; + const softwareLink = getPathWithQueryParams( + paths.SOFTWARE_TITLE_DETAILS(softwareId), + { team_id: currentTeamId } + ); return ( diff --git a/frontend/components/PremiumFeatureIconWithTooltip/PremiumFeatureIconWithTooltip.tsx b/frontend/components/PremiumFeatureIconWithTooltip/PremiumFeatureIconWithTooltip.tsx deleted file mode 100644 index 12fa73b863..0000000000 --- a/frontend/components/PremiumFeatureIconWithTooltip/PremiumFeatureIconWithTooltip.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import CustomLink from "components/CustomLink"; -import Icon from "components/Icon"; -import { uniqueId } from "lodash"; -import React from "react"; -import ReactTooltip, { Place } from "react-tooltip"; -import { COLORS } from "styles/var/colors"; - -interface IPremiumFeatureIconWithTooltip { - tooltipPlace?: Place; - tooltipDelayHide?: number; - tooltipPositionOverrides?: { - leftAdj?: number; - topAdj?: number; - }; -} -const PremiumFeatureIconWithTooltip = ({ - tooltipPlace, - tooltipDelayHide = 100, - tooltipPositionOverrides, -}: IPremiumFeatureIconWithTooltip) => { - const [leftAdj, topAdj] = [ - tooltipPositionOverrides?.leftAdj ?? 0, - tooltipPositionOverrides?.topAdj ?? 0, - ]; - const tipId = uniqueId(); - return ( - - - - - { - return { - left: pos.left + leftAdj, - top: pos.top + topAdj, - }; - }} - > - {`This is a Fleet Premium feature. `} - - - - ); -}; - -export default PremiumFeatureIconWithTooltip; diff --git a/frontend/components/PremiumFeatureIconWithTooltip/PremiumFeatureIconWithToooltip.stories.tsx b/frontend/components/PremiumFeatureIconWithTooltip/PremiumFeatureIconWithToooltip.stories.tsx deleted file mode 100644 index dba2c44d4c..0000000000 --- a/frontend/components/PremiumFeatureIconWithTooltip/PremiumFeatureIconWithToooltip.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; - -import PremiumFeatureIconWithTooltip from "./PremiumFeatureIconWithTooltip"; - -const meta: Meta = { - title: "Components/PremiumFeatureIconWithTooltip", - component: PremiumFeatureIconWithTooltip, -}; - -export default meta; - -type Story = StoryObj; - -export const Basic: Story = {}; diff --git a/frontend/components/PremiumFeatureIconWithTooltip/_styles.scss b/frontend/components/PremiumFeatureIconWithTooltip/_styles.scss deleted file mode 100644 index ec9e7a06d9..0000000000 --- a/frontend/components/PremiumFeatureIconWithTooltip/_styles.scss +++ /dev/null @@ -1,5 +0,0 @@ -.premium-icon-tip { - font-style: normal; - font-size: $x-small; - font-weight: normal; -} diff --git a/frontend/components/PremiumFeatureIconWithTooltip/index.ts b/frontend/components/PremiumFeatureIconWithTooltip/index.ts deleted file mode 100644 index f2c2609222..0000000000 --- a/frontend/components/PremiumFeatureIconWithTooltip/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./PremiumFeatureIconWithTooltip"; diff --git a/frontend/components/Sandbox/SandboxExpiryMessage/SandboxExpiryMessage.tsx b/frontend/components/Sandbox/SandboxExpiryMessage/SandboxExpiryMessage.tsx deleted file mode 100644 index a8d3f130b6..0000000000 --- a/frontend/components/Sandbox/SandboxExpiryMessage/SandboxExpiryMessage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import { browserHistory } from "react-router"; -import PATHS from "router/paths"; - -import Button from "components/buttons/Button"; -import Icon from "components/Icon"; - -const baseClass = "sandbox-expiry-message"; - -interface ISandboxExpiryMessageProps { - expiry: string; - noSandboxHosts?: boolean; -} - -const SandboxExpiryMessage = ({ - expiry, - noSandboxHosts, -}: ISandboxExpiryMessageProps) => { - const openAddHostModal = () => { - browserHistory.push(PATHS.MANAGE_HOSTS_ADD_HOSTS); - }; - - if (noSandboxHosts) { - return ( -
-

Your Fleet Sandbox expires in {expiry}.

-
- -

- Quick tip: Enroll a host to get started. -

-
- -
-
-
- ); - } - - return ( - -

Your Fleet Sandbox expires in {expiry}.

-

- Learn how to use Fleet{" "} - -

-
- ); -}; - -export default SandboxExpiryMessage; diff --git a/frontend/components/Sandbox/SandboxExpiryMessage/_styles.scss b/frontend/components/Sandbox/SandboxExpiryMessage/_styles.scss deleted file mode 100644 index b19dd14b30..0000000000 --- a/frontend/components/Sandbox/SandboxExpiryMessage/_styles.scss +++ /dev/null @@ -1,34 +0,0 @@ -.sandbox-expiry-message { - display: flex; - justify-content: space-between; - align-items: center; - padding: $pad-large $pad-xlarge; - margin-bottom: $pad-large; - background-color: $ui-vibrant-blue-10; - border: 1px solid $ui-vibrant-blue-50; - border-radius: $border-radius; - font-size: $x-small; - color: $core-fleet-black; - font-weight: $regular; - position: relative; // Position in front of settings sticky header space - z-index: 9; // Position in front of settings sticky header space - - p { - margin: 0; - } - - &__tip { - display: flex; - align-items: center; - gap: $pad-small; - - button { - margin-left: $pad-small; - } - } - - .button { - font-size: $xx-small; - font-weight: $bold; - } -} diff --git a/frontend/components/Sandbox/SandboxExpiryMessage/index.ts b/frontend/components/Sandbox/SandboxExpiryMessage/index.ts deleted file mode 100644 index 6269a66378..0000000000 --- a/frontend/components/Sandbox/SandboxExpiryMessage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./SandboxExpiryMessage"; diff --git a/frontend/components/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx b/frontend/components/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx index d918ba66e0..16fee60c94 100644 --- a/frontend/components/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx +++ b/frontend/components/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx @@ -84,19 +84,15 @@ const SoftwareOptionsSelector = ({ )} {formData.automaticInstall && isCustomPackage && ( - - } - > + Installing software over existing installations might cause issues. Fleet's policy may not detect these existing installations. - Please create a test team in Fleet to verify a smooth installation. + Please create a test team in Fleet to verify a smooth installation.{" "} + )}
diff --git a/frontend/components/TabNav/TabNav.stories.tsx b/frontend/components/TabNav/TabNav.stories.tsx new file mode 100644 index 0000000000..c86fc9ed64 --- /dev/null +++ b/frontend/components/TabNav/TabNav.stories.tsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; +import TabText from "components/TabText"; +import TabNav from "./TabNav"; + +const meta: Meta = { + component: TabNav, + title: "Components/TabNav", + parameters: { + backgrounds: { + default: "light", + values: [ + { + name: "light", + value: "#ffffff", + }, + { + name: "dark", + value: "#333333", + }, + ], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const platformSubNav = [ + { name: Basic tab, type: "type1" }, + { name: Basic tab 2, type: "type2" }, + { + name: Disabled tab, + type: "type3", + disabled: true, + }, + { name: Tab with count, type: "type4" }, + { + name: ( + + Tab with error count + + ), + type: "type5", + }, + ]; + + const renderPanel = (type: string) => { + switch (type) { + case "type1": + return
Content for Tab 1
; + case "type2": + return
Content for Tab 2
; + case "type3": + return
Content for Tab 3
; + case "type4": + return
Content for Tab 4
; + case "type5": + return
Content for Tab 5
; + default: + return null; + } + }; + + return ( + + setSelectedTabIndex(index)} + selectedIndex={selectedTabIndex} + > + + {platformSubNav.map((navItem) => ( + + {navItem.name} + + ))} + + {platformSubNav.map((navItem) => ( + +
{renderPanel(navItem.type)}
+
+ ))} +
+
+ ); + }, +}; diff --git a/frontend/components/TabNav/TabNav.tests.tsx b/frontend/components/TabNav/TabNav.tests.tsx new file mode 100644 index 0000000000..efff13cd9d --- /dev/null +++ b/frontend/components/TabNav/TabNav.tests.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; +import TabText from "components/TabText"; +import TabNav from "./TabNav"; + +describe("TabNav", () => { + it("renders tabs and panels correctly", () => { + render( + + + + + Tab 1 + + + Tab 2 + + + +
Content for Tab 1
+
+ +
Content for Tab 2
+
+
+
+ ); + + // Check if tabs are rendered + expect(screen.getByText("Tab 1")).toBeInTheDocument(); + expect(screen.getByText("Tab 2")).toBeInTheDocument(); + + // Check if the first panel content is rendered by default + expect(screen.getByText("Content for Tab 1")).toBeInTheDocument(); + expect(screen.queryByText("Content for Tab 2")).not.toBeInTheDocument(); + }); + + it("switches tabs and displays the correct panel content", () => { + render( + + + + + Tab 1 + + + Tab 2 + + + +
Content for Tab 1
+
+ +
Content for Tab 2
+
+
+
+ ); + + // Switch to the second tab + fireEvent.click(screen.getByText("Tab 2")); + + // Check if the second panel content is displayed + expect(screen.getByText("Content for Tab 2")).toBeInTheDocument(); + expect(screen.queryByText("Content for Tab 1")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/components/TabsWrapper/TabsWrapper.tsx b/frontend/components/TabNav/TabNav.tsx similarity index 65% rename from frontend/components/TabsWrapper/TabsWrapper.tsx rename to frontend/components/TabNav/TabNav.tsx index d788fb4d4e..6b9f59b049 100644 --- a/frontend/components/TabsWrapper/TabsWrapper.tsx +++ b/frontend/components/TabNav/TabNav.tsx @@ -1,7 +1,7 @@ import React from "react"; import classnames from "classnames"; -interface ITabsWrapperProps { +interface ITabNavProps { children: React.ReactChild | React.ReactChild[]; className?: string; } @@ -10,15 +10,12 @@ interface ITabsWrapperProps { * This component exists so we can unify the styles * and overwrite the loaded React Tabs styles. */ -const baseClass = "component__tabs-wrapper"; +const baseClass = "tab-nav"; -const TabsWrapper = ({ - children, - className, -}: ITabsWrapperProps): JSX.Element => { +const TabNav = ({ children, className }: ITabNavProps): JSX.Element => { const classNames = classnames(baseClass, className); return
{children}
; }; -export default TabsWrapper; +export default TabNav; diff --git a/frontend/components/TabNav/_styles.scss b/frontend/components/TabNav/_styles.scss new file mode 100644 index 0000000000..cb5d2a79b9 --- /dev/null +++ b/frontend/components/TabNav/_styles.scss @@ -0,0 +1,134 @@ +.tab-nav { + position: sticky; + top: 0; + background-color: $core-white; + z-index: 2; + + .react-tabs { + &__tab-list { + display: inline-flex; + align-items: flex-start; + gap: $pad-xxlarge; + border-bottom: 1px solid $ui-fleet-black-10; + width: 100%; + height: 43px; + } + .tab-text { + display: flex; /* Ensure text and count are aligned horizontally */ + align-items: center; /* Vertically align items */ + + .tab-text__text { + display: relative; + + // Reserve space for bold text using a hidden pseudo-element + &::before { + content: attr(data-text); /* Same text as the visible one */ + font-weight: bold; /* Mimic bold styling */ + visibility: hidden; /* Keep it invisible */ + position: absolute; /* Prevent it from affecting layout */ + } + } + } + + &__tab { + padding: 5px 0 $pad-medium; + font-size: $x-small; + border: none; + display: inline-flex; + flex-direction: column; + align-items: center; + line-height: 21px; + + &:focus { + box-shadow: none; + outline: 0; + &:after { + left: 0; + bottom: 0; + } + } + + // focus-visible only highlights when tabbing not clicking + &:focus-visible { + .tab-text { + border-radius: $border-radius; + // Outline used instead of border not to shift component + outline: 1px solid $ui-vibrant-blue-25; + outline-offset: -1px; + } + } + + // // Bolding text when the button is active causes a layout shift + // // so we add a hidden pseudo element with the same text string + &:before { + content: attr(data-text); + height: 0; + visibility: hidden; + overflow: hidden; + user-select: none; + pointer-events: none; + font-weight: $bold; + } + + &--selected { + font-weight: $bold; + + &::after { + content: ""; + width: 100%; + height: 0; + border-bottom: 2px solid $core-vibrant-blue; + position: absolute; + bottom: 0; + left: 0; + } + } + + &:hover { + &::after { + content: ""; + width: 100%; + height: 0; + border-bottom: 2px solid $core-vibrant-blue; + position: absolute; + bottom: 0; + left: 0; + } + } + + &--disabled { + cursor: not-allowed; + + &:hover { + &::after { + content: ""; + width: 100%; + height: 0; + border-bottom: 0; + position: absolute; + bottom: 0; + left: 0; + } + } + } + + &.no-count:not(.errors-empty).react-tabs__tab--selected::after { + bottom: -2px; + } + } + &__tab-panel { + .no-results-message { + margin-top: $pad-xxlarge; + font-size: $small; + font-weight: $bold; + + span { + margin-top: $pad-medium; + font-size: $x-small; + font-weight: $regular; + display: block; + } + } + } + } +} diff --git a/frontend/components/TabNav/index.ts b/frontend/components/TabNav/index.ts new file mode 100644 index 0000000000..bbb0fa5b23 --- /dev/null +++ b/frontend/components/TabNav/index.ts @@ -0,0 +1 @@ +export { default } from "./TabNav"; diff --git a/frontend/components/TabText/TabText.tsx b/frontend/components/TabText/TabText.tsx new file mode 100644 index 0000000000..10ac42b2d5 --- /dev/null +++ b/frontend/components/TabText/TabText.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import classnames from "classnames"; + +interface ITabTextProps { + className?: string; + children: React.ReactNode; + count?: number; + /** Changes count badge from default purple to red */ + isErrorCount?: boolean; +} + +/* + * This component exists so we can unify the styles + * and add styles to react-tab text. + */ +const baseClass = "tab-text"; + +const TabText = ({ + className, + children, + count, + isErrorCount = false, +}: ITabTextProps): JSX.Element => { + const classNames = classnames(baseClass, className); + + const countClassNames = classnames(`${baseClass}__count`, { + [`${baseClass}__count--error`]: isErrorCount, + }); + + const renderCount = () => { + if (count && count > 0) { + return
{count.toLocaleString()}
; + } + return undefined; + }; + + return ( +
+
+ {children} +
+ {renderCount()} +
+ ); +}; + +export default TabText; diff --git a/frontend/components/TabText/_styles.scss b/frontend/components/TabText/_styles.scss new file mode 100644 index 0000000000..d28705f3ce --- /dev/null +++ b/frontend/components/TabText/_styles.scss @@ -0,0 +1,23 @@ +.tab-text { + display: flex; + flex-direction: row; + gap: $pad-small; + align-items: center; + height: 21px; + + &__count { + display: flex; + padding: 1px 12px; + justify-content: center; + align-items: center; + background-color: $core-vibrant-blue; + border-radius: 29px; + color: $core-white; + font-weight: $bold; + font-size: $xx-small; + + &--error { + background-color: $core-vibrant-red; + } + } +} diff --git a/frontend/components/TabText/index.ts b/frontend/components/TabText/index.ts new file mode 100644 index 0000000000..2f260c4d2c --- /dev/null +++ b/frontend/components/TabText/index.ts @@ -0,0 +1 @@ +export { default } from "./TabText"; diff --git a/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx b/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx index 27ca436b54..23fd8ce44b 100644 --- a/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx +++ b/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from "react"; import { kebabCase, noop } from "lodash"; -import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; import { ButtonVariant } from "components/buttons/Button/Button"; import Icon from "components/Icon/Icon"; @@ -17,7 +16,6 @@ export interface IActionButtonProps { hideButton?: boolean | ((targetIds: number[]) => boolean); iconSvg?: IconNames; iconPosition?: string; - indicatePremiumFeature?: boolean; } function useActionCallback( @@ -41,7 +39,6 @@ const ActionButton = (buttonProps: IActionButtonProps): JSX.Element | null => { hideButton, iconSvg, iconPosition, - indicatePremiumFeature, } = buttonProps; const onButtonClick = useActionCallback(onActionButtonClick || noop); @@ -62,14 +59,7 @@ const ActionButton = (buttonProps: IActionButtonProps): JSX.Element | null => { return (
- {indicatePremiumFeature && ( - - )} -
); diff --git a/frontend/pages/DashboardPage/cards/Munki/_styles.scss b/frontend/pages/DashboardPage/cards/Munki/_styles.scss index bb4310970b..39ba88050b 100644 --- a/frontend/pages/DashboardPage/cards/Munki/_styles.scss +++ b/frontend/pages/DashboardPage/cards/Munki/_styles.scss @@ -5,7 +5,7 @@ .data-table__wrapper { overflow-x: auto; } - .component__tabs-wrapper .table-container__header { + .tab-nav .table-container__header { display: none; } .data-table-block { @@ -21,7 +21,6 @@ padding-right: 0; } .hosts_count__header { - border-right: 0; padding-right: 0; width: 9%; } @@ -30,9 +29,6 @@ border-left: 0; width: 40px; } - .linkToFilteredHosts__header { - width: 140px; - } } tbody { diff --git a/frontend/pages/DashboardPage/cards/OperatingSystems/OSTableConfig.tsx b/frontend/pages/DashboardPage/cards/OperatingSystems/OSTableConfig.tsx index 83671a0f2f..f1a9ecff18 100644 --- a/frontend/pages/DashboardPage/cards/OperatingSystems/OSTableConfig.tsx +++ b/frontend/pages/DashboardPage/cards/OperatingSystems/OSTableConfig.tsx @@ -7,7 +7,7 @@ import React from "react"; import { CellProps, Column, HeaderProps } from "react-table"; import { InjectedRouter } from "react-router"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import PATHS from "router/paths"; import { formatOperatingSystemDisplayName, @@ -67,12 +67,10 @@ const generateDefaultTableHeaders = ( const { name, os_version_id } = cellProps.row.original; - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamId, - }); - const softwareOsDetailsPath = `${PATHS.SOFTWARE_OS_DETAILS( - os_version_id - )}?${teamQueryParam}`; + const softwareOsDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_OS_DETAILS(os_version_id), + { team_id: teamId } + ); const onClickSoftware = (e: React.MouseEvent) => { // Allows for button to be clickable in a clickable row diff --git a/frontend/pages/DashboardPage/cards/Software/Software.tsx b/frontend/pages/DashboardPage/cards/Software/Software.tsx index a8b8d98741..be03fc5b53 100644 --- a/frontend/pages/DashboardPage/cards/Software/Software.tsx +++ b/frontend/pages/DashboardPage/cards/Software/Software.tsx @@ -4,11 +4,12 @@ import { Row } from "react-table"; import PATHS from "router/paths"; import { InjectedRouter } from "react-router"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { ISoftwareResponse } from "interfaces/software"; import { ITableQueryData } from "components/TableContainer/TableContainer"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; import TableContainer from "components/TableContainer"; import TableDataError from "components/DataError"; import Spinner from "components/Spinner"; @@ -56,11 +57,10 @@ const Software = ({ const tableHeaders = useMemo(() => generateTableHeaders(teamId), [teamId]); const handleRowSelect = (row: IRowProps) => { - const queryParams = { software_id: row.original.id, team_id: teamId }; - - const path = queryParams - ? `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(queryParams)}` - : PATHS.MANAGE_HOSTS; + const path = getPathWithQueryParams(PATHS.MANAGE_HOSTS, { + software_id: row.original.id, + team_id: teamId, + }); router.push(path); }; @@ -76,11 +76,15 @@ const Software = ({
)}
- + - All - Vulnerable + + All + + + Vulnerable + {!isSoftwareFetching && errorSoftware ? ( @@ -129,7 +133,7 @@ const Software = ({ )} - +
); diff --git a/frontend/pages/DashboardPage/cards/Software/_styles.scss b/frontend/pages/DashboardPage/cards/Software/_styles.scss index 5ff0a14a7d..71ea46b649 100644 --- a/frontend/pages/DashboardPage/cards/Software/_styles.scss +++ b/frontend/pages/DashboardPage/cards/Software/_styles.scss @@ -19,7 +19,7 @@ .form-field--dropdown { margin: 0; } - .component__tabs-wrapper .table-container__header { + .tab-nav .table-container__header { display: none; } &__empty-software { @@ -52,7 +52,6 @@ padding-right: 0; } .hosts_count__header { - border-right: 0; padding-right: 0; width: 60px; } diff --git a/frontend/pages/DashboardPage/cards/TotalHosts/TotalHosts.tsx b/frontend/pages/DashboardPage/cards/TotalHosts/TotalHosts.tsx index 8c0593b644..6447a0a10e 100644 --- a/frontend/pages/DashboardPage/cards/TotalHosts/TotalHosts.tsx +++ b/frontend/pages/DashboardPage/cards/TotalHosts/TotalHosts.tsx @@ -1,7 +1,7 @@ import React from "react"; import PATHS from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import HostCountCard from "../HostCountCard"; @@ -22,14 +22,12 @@ const TotalHosts = ({ }: ITotalHostsProps): JSX.Element => { // build the manage hosts URL filtered by low disk space only // currently backend cannot filter by both low disk space and label - const queryParams = { - team_id: currentTeamId, - }; - const queryString = buildQueryStringFromParams(queryParams); const endpoint = selectedPlatformLabelId ? PATHS.MANAGE_HOSTS_LABEL(selectedPlatformLabelId) : PATHS.MANAGE_HOSTS; - const path = `${endpoint}?${queryString}`; + const path = getPathWithQueryParams(endpoint, { + team_id: currentTeamId, + }); return ( ); }; @@ -93,9 +94,9 @@ const PlatformHostCounts = ({ iconName="windows" count={windowsCount} title="Windows" - path={PATHS.MANAGE_HOSTS_LABEL(windowsLabelId).concat( - teamId !== undefined ? `?team_id=${teamId}` : "" - )} + path={getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(windowsLabelId), { + team_id: teamId, + })} /> ); }; @@ -115,9 +116,9 @@ const PlatformHostCounts = ({ iconName="linux" count={linuxCount} title="Linux" - path={PATHS.MANAGE_HOSTS_LABEL(linuxLabelId).concat( - teamId !== undefined ? `?team_id=${teamId}` : "" - )} + path={getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(linuxLabelId), { + team_id: teamId, + })} /> ); }; @@ -138,9 +139,9 @@ const PlatformHostCounts = ({ iconName="chrome" count={chromeCount} title="Chromebooks" - path={PATHS.MANAGE_HOSTS_LABEL(chromeLabelId).concat( - teamId !== undefined ? `?team_id=${teamId}` : "" - )} + path={getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(chromeLabelId), { + team_id: teamId, + })} /> ); }; @@ -161,9 +162,9 @@ const PlatformHostCounts = ({ iconName="iOS" count={iosCount} title="iPhones" - path={PATHS.MANAGE_HOSTS_LABEL(iosLabelId).concat( - teamId !== undefined ? `?team_id=${teamId}` : "" - )} + path={getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(iosLabelId), { + team_id: teamId, + })} /> ); }; @@ -184,9 +185,9 @@ const PlatformHostCounts = ({ iconName="iPadOS" count={ipadosCount} title="iPads" - path={PATHS.MANAGE_HOSTS_LABEL(ipadosLabelId).concat( - teamId !== undefined ? `?team_id=${teamId}` : "" - )} + path={getPathWithQueryParams(PATHS.MANAGE_HOSTS_LABEL(ipadosLabelId), { + team_id: teamId, + })} /> ); }; diff --git a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx index c278cd5a3e..71570f209e 100644 --- a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx +++ b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx @@ -6,7 +6,8 @@ import PATHS from "router/paths"; import { AppContext } from "context/app"; import useTeamIdParam from "hooks/useTeamIdParam"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; import MainContent from "components/MainContent"; import TeamsDropdown from "components/TeamsDropdown"; import { parseOSUpdatesCurrentVersionsQueryParams } from "./OSUpdates/components/CurrentVersionSection/CurrentVersionSection"; @@ -112,7 +113,7 @@ const ManageControlsPage = ({ const renderBody = () => { return (
- + { return ( - {navItem.name} + {navItem.name} ); })} - + {React.cloneElement(children, { teamIdForApi, currentPage: page, diff --git a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregate.tsx b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregate.tsx index 51de764120..eb7ae767d5 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregate.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregate.tsx @@ -1,7 +1,7 @@ import React from "react"; import paths from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { MdmProfileStatus } from "interfaces/mdm"; import { HOSTS_QUERY_PARAMS } from "services/entities/hosts"; import { ProfileStatusSummaryResponse } from "services/entities/mdm"; @@ -33,12 +33,12 @@ const ProfileStatusCount = ({ hostCount, tooltipText, }: IProfileStatusCountProps) => { - const linkHostsByStatus = `${paths.MANAGE_HOSTS}?${buildQueryStringFromParams( - { - team_id: teamId, - [HOSTS_QUERY_PARAMS.OS_SETTINGS]: statusValue, - } - )}`; + const hostsByStatusParams = { + team_id: teamId, + [HOSTS_QUERY_PARAMS.OS_SETTINGS]: statusValue, + }; + + const path = getPathWithQueryParams(paths.MANAGE_HOSTS, hostsByStatusParams); return (
@@ -49,7 +49,7 @@ const ProfileStatusCount = ({ layout="vertical" valueClassName={`${baseClass}__status-indicator-value`} /> - {hostCount} hosts + {hostCount} hosts
); }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx index ae267d3ad1..8b6d6fe5cb 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx @@ -5,7 +5,7 @@ import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import diskEncryptionAPI, { IDiskEncryptionSummaryResponse, @@ -60,8 +60,8 @@ const DiskEncryptionTable = ({ [HOSTS_QUERY_PARAMS.DISK_ENCRYPTION]: status?.value, team_id: teamId, }; - const endpoint = PATHS.MANAGE_HOSTS; - const path = `${endpoint}?${buildQueryStringFromParams(queryParams)}`; + const path = getPathWithQueryParams(PATHS.MANAGE_HOSTS, queryParams); + router.push(path); }, [router] diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx index d69a9ec838..bc02c5800f 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; import CustomLink from "components/CustomLink"; import { SUPPORT_LINK } from "utilities/constants"; @@ -62,27 +63,25 @@ const PlatformTabs = ({ return (
- + - {/* Bolding text when the tab is active causes a layout shift so - we add a hidden pseudo element with the same text string */} - macOS + macOS {isWindowsMdmEnabled && ( - Windows + Windows )} - iOS + iOS - iPadOS + iPadOS {isAndroidMdmEnabled && ( @@ -149,7 +148,7 @@ const PlatformTabs = ({ )} - +
); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx index 8ee18058ad..c70cbcde9f 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx @@ -2,6 +2,8 @@ import React, { useContext, useState } from "react"; import { useQuery } from "react-query"; import { NotificationContext } from "context/notification"; +import { AppContext } from "context/app"; +import { getPathWithQueryParams } from "utilities/url"; import scriptAPI from "services/entities/scripts"; import Button from "components/buttons/Button"; @@ -38,6 +40,7 @@ const EditScriptModal = ({ onExit, }: IEditScriptModal) => { const { renderFlash } = useContext(NotificationContext); + const { currentTeam } = useContext(AppContext); // Editable script content const [scriptFormData, setScriptFormData] = useState(""); @@ -115,11 +118,23 @@ const EditScriptModal = ({ />
To run this script on a host, go to the{" "} - page and select - a host. + {" "} + page and select a host.
To run the script across multiple hosts, add a policy automation on - the page. + the{" "} + {" "} + page.
void; + teamIdForApi?: number; } const ScriptDetailsModal = ({ @@ -75,6 +77,7 @@ const ScriptDetailsModal = ({ isScriptContentError, isHidden = false, onClickRunDetails, + teamIdForApi, }: IScriptDetailsModalProps) => { // For scrollable modal const [isTopScrolling, setIsTopScrolling] = useState(false); @@ -272,11 +275,23 @@ const ScriptDetailsModal = ({ {runScriptHelpText && (
To run this script on a host, go to the{" "} - page and select - a host. + {" "} + page and select a host.
To run the script across multiple hosts, add a policy automation on - the page. + the{" "} + {" "} + page.
)}
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageTable/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageTable/_styles.scss index 3533b35ffc..7a0137a330 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageTable/_styles.scss +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageTable/_styles.scss @@ -10,10 +10,6 @@ min-width: auto; } - .data-table-block th:nth-last-child(2) { - border-right: 0; - } - @media (max-width: $break-lg) { .view-hosts-link { span { diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx index 35789ee3b8..493c29c71c 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx @@ -51,8 +51,6 @@ const SetupAssistantProfileUploader = ({ text="Learn more" className={`${baseClass}__new-tab`} newTab - color="core-fleet-black" - iconColor="core-fleet-white" /> ); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx index ef9f244ab2..08ff83270a 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx @@ -4,14 +4,15 @@ import { InjectedRouter } from "react-router"; import { Location } from "history"; import PATHS from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { QueryContext } from "context/query"; import useToggleSidePanel from "hooks/useToggleSidePanel"; import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import MainContent from "components/MainContent"; import BackLink from "components/BackLink"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; import SidePanelContent from "components/SidePanelContent"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; @@ -72,11 +73,9 @@ const SoftwareAddPage = ({ (i: number): void => { setSidePanelOpen(false); // Only query param to persist between tabs is team id - const teamIdParam = buildQueryStringFromParams({ + const navPath = getPathWithQueryParams(addSoftwareSubNav[i].pathname, { team_id: location.query.team_id, }); - - const navPath = addSoftwareSubNav[i].pathname.concat(`?${teamIdParam}`); router.replace(navPath); }, [location.query.team_id, router, setSidePanelOpen] @@ -87,9 +86,9 @@ const SoftwareAddPage = ({ // is not provieded. if (!location.query.team_id) { router.replace( - `${location.pathname}?${buildQueryStringFromParams({ + getPathWithQueryParams(location.pathname, { team_id: APP_CONTEXT_NO_TEAM_ID, - })}` + }) ); return null; } @@ -98,9 +97,9 @@ const SoftwareAddPage = ({ setSelectedOsqueryTable(tableName); }; - const backUrl = `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({ + const backUrl = getPathWithQueryParams(PATHS.SOFTWARE_TITLES, { team_id: location.query.team_id, - })}`; + }); return ( <> @@ -112,7 +111,7 @@ const SoftwareAddPage = ({ className={`${baseClass}__back-to-software`} />

Add software

- + { return ( - {navItem.name} + {navItem.name} ); })} - + {React.cloneElement(children, { router, currentTeamId: parseInt(location.query.team_id, 10), diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx index b5c8280c0c..6aabab0e70 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx @@ -22,7 +22,7 @@ import Spinner from "components/Spinner"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; import Button from "components/buttons/Button"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import SoftwareVppForm from "./SoftwareVppForm"; import { getErrorMessage, teamHasVPPToken } from "./helpers"; import { ISoftwareVppFormData } from "./SoftwareVppForm/SoftwareVppForm"; @@ -150,9 +150,7 @@ const SoftwareAppStoreVpp = ({ ...(showAvailableForInstallOnly && { available_for_install: true }), }; - router.push( - `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(queryParams)}` - ); + router.push(getPathWithQueryParams(PATHS.SOFTWARE_TITLES, queryParams)); }; const onAddSoftware = async (formData: ISoftwareVppFormData) => { diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx index 2196f25058..e7d5e26619 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx @@ -5,7 +5,7 @@ import { useQuery } from "react-query"; import PATHS from "router/paths"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import { getFileDetails, IFileDetails } from "utilities/file/fileUtils"; -import { buildQueryStringFromParams, QueryParams } from "utilities/url"; +import { getPathWithQueryParams, QueryParams } from "utilities/url"; import softwareAPI, { MAX_FILE_SIZE_BYTES, MAX_FILE_SIZE_MB, @@ -84,9 +84,9 @@ const SoftwareCustomPackage = ({ const onCancel = () => { router.push( - `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({ + getPathWithQueryParams(PATHS.SOFTWARE_TITLES, { team_id: currentTeamId, - })}` + }) ); }; @@ -130,7 +130,7 @@ const SoftwareCustomPackage = ({ newQueryParams.available_for_install = true; } router.push( - `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}` + getPathWithQueryParams(PATHS.SOFTWARE_TITLES, newQueryParams) ); renderFlash( diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx index b024089ff6..1cbabcbfed 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx @@ -6,10 +6,9 @@ import { InjectedRouter } from "react-router"; import { useErrorHandler } from "react-error-boundary"; import PATHS from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import softwareAPI from "services/entities/software"; -import teamPoliciesAPI from "services/entities/team_policies"; import labelsAPI, { getCustomLabels } from "services/entities/labels"; import { QueryContext } from "context/query"; import { AppContext } from "context/app"; @@ -129,6 +128,7 @@ const FleetMaintainedAppDetailsPage = ({ } const { renderFlash } = useContext(NotificationContext); + const handlePageError = useErrorHandler(); const { isPremiumTier } = useContext(AppContext); const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext( @@ -180,9 +180,10 @@ const FleetMaintainedAppDetailsPage = ({ setShowAppDetailsModal(true); }; - const backToAddSoftwareUrl = `${ - PATHS.SOFTWARE_ADD_FLEET_MAINTAINED - }?${buildQueryStringFromParams({ team_id: teamId })}`; + const backToAddSoftwareUrl = getPathWithQueryParams( + PATHS.SOFTWARE_ADD_FLEET_MAINTAINED, + { team_id: teamId } + ); const onCancel = () => { router.push(backToAddSoftwareUrl); @@ -206,22 +207,19 @@ const FleetMaintainedAppDetailsPage = ({ titleId = res.software_title_id; router.push( - `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({ + getPathWithQueryParams(PATHS.SOFTWARE_TITLES, { team_id: teamId, available_for_install: true, - })}` + }) ); + renderFlash( "success", <> {fleetApp?.name} successfully added. ); - // } } catch (error) { - // quick exit if there was an error adding the software. Skip the policy - // creation. - const ae = (typeof error === "object" ? error : {}) as AxiosResponse; const errorMessage = getErrorMessage(ae); @@ -236,46 +234,8 @@ const FleetMaintainedAppDetailsPage = ({ } else { renderFlash("error", DEFAULT_ERROR_MESSAGE); } - - setShowAddFleetAppSoftwareModal(false); - return; } - // If the install type is automatic we now need to create the new policy. - // if (installType === "automatic" && fleetApp) { - // try { - // await teamPoliciesAPI.create({ - // name: getFleetAppPolicyName(fleetApp.name), - // description: getFleetAppPolicyDescription(fleetApp.name), - // query: getFleetAppPolicyQuery(fleetApp.name), - // team_id: parseInt(teamId, 10), - // software_title_id: titleId, - // platform: "darwin", - // }); - - // renderFlash( - // "success", - // <> - // {fleetApp?.name} successfully added. - // , - // { persistOnPageChange: true } - // ); - // } catch (e) { - // renderFlash("error", AUTOMATIC_POLICY_ERROR_MESSAGE, { - // persistOnPageChange: true, - // }); - // } - - // // for automatic install we redirect on both a successful and error policy - // // add because the software was already successfuly added. - // router.push( - // `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({ - // team_id: teamId, - // available_for_install: true, - // })}` - // ); - // } - setShowAddFleetAppSoftwareModal(false); }; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx index 29aae9ea0c..40dac18ee7 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx @@ -4,7 +4,7 @@ import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { ISoftwareFleetMaintainedAppsResponse } from "services/entities/software"; import { getNextLocationPath } from "utilities/helpers"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { IFleetMaintainedApp } from "interfaces/software"; import TableContainer from "components/TableContainer"; @@ -123,11 +123,10 @@ const FleetMaintainedAppsTable = ({ ); const handleRowClick = (row: IRowProps) => { - const path = `${PATHS.SOFTWARE_FLEET_MAINTAINED_DETAILS( - row.original.id - )}?${buildQueryStringFromParams({ - team_id: teamId, - })}`; + const path = getPathWithQueryParams( + PATHS.SOFTWARE_FLEET_MAINTAINED_DETAILS(row.original.id), + { team_id: teamId } + ); router.push(path); }; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTableConfig.tsx index 42f2012a87..55500fc87a 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTableConfig.tsx @@ -6,7 +6,7 @@ import PATHS from "router/paths"; import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config"; import { APPLE_PLATFORM_DISPLAY_NAMES } from "interfaces/platform"; import { IFleetMaintainedApp } from "interfaces/software"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import TextCell from "components/TableContainer/DataTable/TextCell"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; @@ -31,11 +31,10 @@ export const generateTableConfig = ( Cell: (cellProps: ITableStringCellProps) => { const { name, id } = cellProps.row.original; - const path = `${PATHS.SOFTWARE_FLEET_MAINTAINED_DETAILS( - id - )}?${buildQueryStringFromParams({ - team_id: teamId, - })}`; + const path = getPathWithQueryParams( + PATHS.SOFTWARE_FLEET_MAINTAINED_DETAILS(id), + { team_id: teamId } + ); return ; }, diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx index 633a6d98c4..81b928efdb 100644 --- a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx @@ -21,7 +21,7 @@ import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable import { IOSVersionsResponse } from "services/entities/operating_systems"; import generateTableConfig from "pages/DashboardPage/cards/OperatingSystems/OSTableConfig"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { getNextLocationPath } from "utilities/helpers"; import { SelectedPlatform } from "interfaces/platform"; @@ -162,12 +162,10 @@ const SoftwareOSTable = ({ }, [data, router, teamId]); const handleRowSelect = (row: IRowProps) => { - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamId, - }); - const path = `${PATHS.SOFTWARE_OS_DETAILS( - Number(row.original.os_version_id) - )}?${teamQueryParam}`; + const path = getPathWithQueryParams( + PATHS.SOFTWARE_OS_DETAILS(Number(row.original.os_version_id)), + { team_id: teamId } + ); router.push(path); }; diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index 21a8e18d55..635d2c48bb 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -23,15 +23,16 @@ import { AppContext } from "context/app"; import { NotificationContext } from "context/notification"; import useTeamIdParam from "hooks/useTeamIdParam"; import { - buildQueryStringFromParams, convertParamsToSnakeCase, + getPathWithQueryParams, } from "utilities/url"; import { getNextLocationPath } from "utilities/helpers"; import Button from "components/buttons/Button"; import MainContent from "components/MainContent"; import TeamsHeader from "components/TeamsHeader"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal"; import AddSoftwareModal from "./components/AddSoftwareModal"; @@ -298,7 +299,9 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { setShowAddSoftwareModal(true); } else { router.push( - `${PATHS.SOFTWARE_ADD_FLEET_MAINTAINED}?team_id=${currentTeamId}` + getPathWithQueryParams(PATHS.SOFTWARE_ADD_FLEET_MAINTAINED, { + team_id: currentTeamId, + }) ); } }, [currentTeamId, router]); @@ -344,12 +347,16 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { setResetPageIndex(true); // Fixes flakey page reset in table state when switching between tabs // Only query param to persist between tabs is team id - const teamIdParam = buildQueryStringFromParams({ + const teamIdParam = { team_id: location?.query.team_id, page: 0, // Fixes flakey page reset in API call when switching between tabs - }); + }; + + const navPath = getPathWithQueryParams( + softwareSubNav[i].pathname, + teamIdParam + ); - const navPath = softwareSubNav[i].pathname.concat(`?${teamIdParam}`); router.replace(navPath); }, [location, router] @@ -412,7 +419,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { const renderBody = () => { return (
- + { {softwareSubNav.map((navItem) => { return ( - {navItem.name} + {navItem.name} ); })} - + {React.cloneElement(children, { router, isSoftwareEnabled: Boolean( diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/AutomaticInstallModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/AutomaticInstallModal.tsx index f056ad34ce..9f9889382c 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/AutomaticInstallModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/AutomaticInstallModal.tsx @@ -1,11 +1,13 @@ import React from "react"; +import { Link } from "react-router"; +import paths from "router/paths"; import { ISoftwareInstallPolicy } from "interfaces/software"; +import { getPathWithQueryParams } from "utilities/url"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; -import { Link } from "react-router"; const baseClass = "automatic-install-modal"; @@ -17,7 +19,13 @@ interface IPoliciesListItemProps { const PoliciesListItem = ({ teamId, policy }: IPoliciesListItemProps) => { return (
  • - {policy.name} + + {policy.name} +
  • ); }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx index 8d49bd5292..da85f13f13 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx @@ -10,7 +10,7 @@ import { } from "interfaces/software"; import softwareAPI from "services/entities/software"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import Card from "components/Card"; @@ -101,11 +101,11 @@ const InstallerStatusCount = ({ teamId, }: IInstallerStatusCountProps) => { const displayData = STATUS_DISPLAY_OPTIONS[status]; - const linkUrl = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({ + const linkUrl = getPathWithQueryParams(PATHS.MANAGE_HOSTS, { software_title_id: softwareId, software_status: status, team_id: teamId, - })}`; + }); return ( ; } const { id } = cellProps.row.original; - const teamQueryParam = buildQueryStringFromParams({ team_id: teamId }); - const softwareVersionDetailsPath = `${PATHS.SOFTWARE_VERSION_DETAILS( - id.toString() - )}?${teamQueryParam}`; + const softwareVersionDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_VERSION_DETAILS(id.toString()), + { team_id: teamId } + ); return ( { if (row.original.id) { - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamId, - }); - - const path = `${PATHS.SOFTWARE_TITLE_DETAILS( - row.original.id.toString() - )}?${teamQueryParam}`; + const path = getPathWithQueryParams( + PATHS.SOFTWARE_TITLE_DETAILS(row.original.id.toString()), + { team_id: teamId } + ); router.push(path); } diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx index 5082286681..0ca69132b4 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx @@ -9,7 +9,7 @@ import { } from "interfaces/software"; import PATHS from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; @@ -62,10 +62,10 @@ const getSoftwareNameCellData = ( softwareTitle: ISoftwareTitle, teamId?: number ) => { - const teamQueryParam = buildQueryStringFromParams({ team_id: teamId }); - const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS( - softwareTitle.id.toString() - )}?${teamQueryParam}`; + const softwareTitleDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_TITLE_DETAILS(softwareTitle.id.toString()), + { team_id: teamId } + ); const { software_package, app_store_app } = softwareTitle; let hasPackage = false; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareVersionsTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareVersionsTableConfig.tsx index 0be57bf962..b46b0419ad 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareVersionsTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareVersionsTableConfig.tsx @@ -2,7 +2,7 @@ import React from "react"; import { CellProps, Column } from "react-table"; import { InjectedRouter } from "react-router"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { formatSoftwareType, ISoftwareVersion, @@ -45,12 +45,12 @@ const generateTableHeaders = ( Cell: (cellProps: ITableStringCellProps) => { const { id, name, source } = cellProps.row.original; - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamId, - }); - const softwareVersionDetailsPath = `${PATHS.SOFTWARE_VERSION_DETAILS( - id.toString() - )}?${teamQueryParam}`; + const softwareVersionDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_VERSION_DETAILS(id.toString()), + { + team_id: teamId, + } + ); return ( { if (row.original.cve) { const cveName = row.original.cve.toString(); - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamId, - }); - const softwareVulnerabilityDetailsPath = `${PATHS.SOFTWARE_VULNERABILITY_DETAILS( - cveName - )}?${teamQueryParam}`; + const softwareVulnerabilityDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_VULNERABILITY_DETAILS(cveName), + { + team_id: teamId, + } + ); router.push(softwareVulnerabilityDetailsPath); } diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/VulnerabilitiesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/VulnerabilitiesTableConfig.tsx index 92881a3c47..36660acc4e 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/VulnerabilitiesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/VulnerabilitiesTableConfig.tsx @@ -4,7 +4,7 @@ import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { formatSeverity } from "utilities/helpers"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { formatOperatingSystemDisplayName } from "interfaces/operating_system"; import { IVulnerability } from "interfaces/vulnerability"; @@ -78,10 +78,10 @@ const generateTableHeaders = ( const { cve } = cellProps.row.original; - const teamQueryParam = buildQueryStringFromParams({ team_id: teamId }); - const softwareVulnerabilitiesDetailsPath = `${PATHS.SOFTWARE_VULNERABILITY_DETAILS( - cve - )}?${teamQueryParam}`; + const softwareVulnerabilitiesDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_VULNERABILITY_DETAILS(cve), + { team_id: teamId } + ); const onClickVulnerability = (e: React.MouseEvent) => { // Allows for button to be clickable in a clickable row diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx index 342192f973..1565c492bd 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx @@ -5,6 +5,7 @@ import { Row } from "react-table"; import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; +import { getPathWithQueryParams } from "utilities/url"; import { IVulnerabilityResponse } from "services/entities/vulnerabilities"; import Card from "components/Card"; import TableContainer from "components/TableContainer"; @@ -42,11 +43,10 @@ const SoftwareVulnOSVersions = ({ if (row.original.os_version_id) { const softwareOsVersionId = Number(row.original.os_version_id); - const endpoint = PATHS.SOFTWARE_OS_DETAILS(softwareOsVersionId); - // since No Teams not supported on this page, falsiness of 0 is okay - const softwareOsDetailsPath = teamIdForApi - ? `${endpoint}?team_id=${teamIdForApi}` - : endpoint; + const softwareOsDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_OS_DETAILS(softwareOsVersionId), + { team_id: teamIdForApi } + ); router.push(softwareOsDetailsPath); } diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SwVulnOSTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SwVulnOSTableConfig.tsx index 2ac119dd25..10007a56b5 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SwVulnOSTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SwVulnOSTableConfig.tsx @@ -15,6 +15,7 @@ import { INumberCellProps, IStringCellProps, } from "interfaces/datatable_config"; +import { getPathWithQueryParams } from "utilities/url"; type ISWVulnTableColumnConfig = Column; @@ -34,11 +35,12 @@ const generateColumnConfigs = ( accessor: "name_only", Cell: ({ row }: ITableStringCellProps) => { const { name, os_version_id, platform } = row.original; - const endpoint = PATHS.SOFTWARE_OS_DETAILS(os_version_id); // since No Teams not supported on this page, falsiness of 0 is okay - const path = teamIdForApi - ? `${endpoint}?team_id=${teamIdForApi}` - : endpoint; + const path = getPathWithQueryParams( + PATHS.SOFTWARE_OS_DETAILS(os_version_id), + { team_id: teamIdForApi } + ); + return ( { if (row.original.id) { const softwareVersionId = row.original.id; - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamIdForApi, - }); - const endpoint = PATHS.SOFTWARE_VERSION_DETAILS( - softwareVersionId.toString() - ); - // since No Teams not supported on this page, falsiness of 0 is okay - const softwareVersionDetailsPath = teamIdForApi - ? `${endpoint}?${teamQueryParam}` - : endpoint; + const softwareVersionDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_VERSION_DETAILS(softwareVersionId.toString()), + { + team_id: teamIdForApi, + } + ); router.push(softwareVersionDetailsPath); } diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SwVulnSwTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SwVulnSwTableConfig.tsx index 3de15eebb3..9d94c43787 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SwVulnSwTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SwVulnSwTableConfig.tsx @@ -11,6 +11,7 @@ import TextCell from "components/TableContainer/DataTable/TextCell"; import ViewAllHostsLink from "components/ViewAllHostsLink"; import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; import { InjectedRouter } from "react-router"; +import { getPathWithQueryParams } from "utilities/url"; type SwVulnTableColumnConfig = Column; @@ -33,11 +34,12 @@ const generateColumnConfigs = ( accessor: "name", Cell: ({ row }: ITableStringCellProps) => { const { name, id } = row.original; - const endpoint = PATHS.SOFTWARE_VERSION_DETAILS(id.toString()); // since No Teams not supported on this page, falsiness of 0 is okay - const path = teamIdForApi - ? `${endpoint}?team_id=${teamIdForApi}` - : endpoint; + const path = getPathWithQueryParams( + PATHS.SOFTWARE_VERSION_DETAILS(id.toString()), + { team_id: teamIdForApi } + ); + return ( { if (row.original.cve) { const cveName = row.original.cve.toString(); - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamIdForApi, - }); - const softwareVulnerabilityDetailsPath = `${PATHS.SOFTWARE_VULNERABILITY_DETAILS( - cveName - )}?${teamQueryParam}`; + const softwareVulnerabilityDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_VULNERABILITY_DETAILS(cveName), + { + team_id: teamIdForApi, + } + ); router.push(softwareVulnerabilityDetailsPath); } diff --git a/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTableConfig.tsx b/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTableConfig.tsx index df0f85a698..34848e9ed0 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTableConfig.tsx @@ -2,7 +2,7 @@ import React from "react"; import { InjectedRouter } from "react-router"; import { formatSeverity } from "utilities/helpers"; -import { buildQueryStringFromParams } from "utilities/url"; +import { getPathWithQueryParams } from "utilities/url"; import { ISoftwareVulnerability } from "interfaces/software"; import paths from "router/paths"; @@ -59,13 +59,11 @@ const generateTableConfig = ( Header: "Vulnerability", Cell: ({ cell: { value } }: ITextCellProps) => { const cveName = value.toString(); - const teamQueryParam = buildQueryStringFromParams({ - team_id: teamId, - }); - const softwareVulnerabilityDetailsPath = `${paths.SOFTWARE_VULNERABILITY_DETAILS( - cveName - )}?${teamQueryParam}`; + const softwareVulnerabilityDetailsPath = getPathWithQueryParams( + paths.SOFTWARE_VULNERABILITY_DETAILS(cveName), + { team_id: teamId } + ); return ( diff --git a/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/_styles.scss b/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/_styles.scss index 736bb51064..9edb4adcc4 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/_styles.scss +++ b/frontend/pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/_styles.scss @@ -4,10 +4,6 @@ &__wrapper { overflow-x: auto; } - - th:nth-last-child(2) { - border-right: 0; - } } // used to position header text with premium icon correctly diff --git a/frontend/pages/SoftwarePage/helpers.tsx b/frontend/pages/SoftwarePage/helpers.tsx index 044b041849..5ff3b39b3b 100644 --- a/frontend/pages/SoftwarePage/helpers.tsx +++ b/frontend/pages/SoftwarePage/helpers.tsx @@ -64,97 +64,6 @@ export const generateSecretErrMsg = (err: unknown) => { /** Corresponds to automatic_install_policies */ export type InstallType = "manual" | "automatic"; -interface IInstallTypeSection { - className: string; - installType: InstallType; - onChangeInstallType: (value: string) => void; - isCustomPackage?: boolean; - isExeCustomPackage?: boolean; -} - -// Used in FleetAppDetailsForm and PackageForm -export const InstallTypeSection = ({ - className, - installType, - onChangeInstallType, - isCustomPackage = false, - isExeCustomPackage = false, -}: IInstallTypeSection) => { - const isAutomaticDisabled = isExeCustomPackage; - const AUTOMATIC_DISABLED_TOOLTIP = ( - <> - Fleet can't create a policy to detect existing installations for -
    .exe packages. To automatically install an .exe, add a custom -
    policy and enable the install software automation on the -
    Policies page. - - ); - - return ( -
    - Install -
    - - Manually install on the Host details page for each host. - - } - /> - - Automatically install on each host that's{" "} - - If the host already has any version of this -
    software, it won't be installed. - - } - > - missing this software -
    - . Policy that triggers install can be customized after software is - added. - - } - /> -
    - {installType === "automatic" && isCustomPackage && ( - - } - > - Installing software over existing installations might cause issues. - Fleet's policy may not detect these existing installations. - Please create a test team in Fleet to verify a smooth installation. - - )} -
    - ); -}; - export const getInstallType = ( softwarePackage: ISoftwarePackage ): InstallType => { diff --git a/frontend/pages/admin/AdminWrapper.tsx b/frontend/pages/admin/AdminWrapper.tsx index 9efa69ae67..98666ac002 100644 --- a/frontend/pages/admin/AdminWrapper.tsx +++ b/frontend/pages/admin/AdminWrapper.tsx @@ -4,8 +4,9 @@ import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; import MainContent from "components/MainContent"; +import TabText from "components/TabText"; import classnames from "classnames"; interface ISettingSubNavItem { @@ -77,7 +78,7 @@ const AdminWrapper = ({ return (
    - +

    Settings

    -
    + {children}
    diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx index a3f82ade0f..114c6292a5 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx @@ -20,6 +20,7 @@ import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatu import Icon from "components/Icon"; import Card from "components/Card"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import { getPathWithQueryParams } from "utilities/url"; const CREATING_SERVICE_ACCOUNT = "https://www.fleetdm.com/learn-more-about/creating-service-accounts"; @@ -75,7 +76,7 @@ const baseClass = "calendars-integration"; const Calendars = (): JSX.Element => { const { renderFlash } = useContext(NotificationContext); - const { isPremiumTier } = useContext(AppContext); + const { currentTeam, isPremiumTier } = useContext(AppContext); const [formData, setFormData] = useState({ domain: "", @@ -422,7 +423,9 @@ const Calendars = (): JSX.Element => {

    Now head over to{" "} {" "} to finish setup. diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx index 3a35856f60..eaf07701c0 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx @@ -24,7 +24,8 @@ import sortUtils from "utilities/sort"; import ActionButtons from "components/buttons/ActionButtons/ActionButtons"; import Spinner from "components/Spinner"; -import TabsWrapper from "components/TabsWrapper"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; import BackLink from "components/BackLink"; import TeamsDropdown from "components/TeamsDropdown"; import MainContent from "components/MainContent"; @@ -387,69 +388,66 @@ const TeamDetailsWrapper = ({ return ( <> - - {isGlobalAdmin ? ( -

    - -
    - ) : ( - <> - )} -
    -
    - {userTeams?.length === 1 ? ( -

    {currentTeamDetails.name}

    - ) : ( - - )} - {!!hostsTotalDisplay && ( - - {hostsTotalDisplay} - - )} -
    - + {isGlobalAdmin ? ( +
    +
    + ) : ( + <> + )} +
    +
    + {userTeams?.length === 1 ? ( +

    {currentTeamDetails.name}

    + ) : ( + + )} + {!!hostsTotalDisplay && ( + + {hostsTotalDisplay} + + )} +
    + +
    + - + {showAddHostsModal && ( { + return ( + + This user was created using fleetctl and +
    only has API access.{" "} + + + } + tipOffset={14} + position="top" + showArrow + underline={false} + > + API +
    + ); +}; + // NOTE: cellProps come from react-table // more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties const generateColumnConfigs = ( @@ -72,52 +97,17 @@ const generateColumnConfigs = ( sortType: "caseInsensitive", accessor: "name", Cell: (cellProps: ICellProps) => { - const formatter = (val: string) => { - const apiOnlyUser = - "api_only" in cellProps.row.original - ? cellProps.row.original.api_only - : false; + const apiOnlyUser = + "api_only" in cellProps.row.original + ? cellProps.row.original.api_only + : false; - return ( - <> - {val} - {apiOnlyUser && ( - <> - - API - - - <> - This user was created using fleetctl and -
    only has API access.{" "} - - -
    - - )} - - ); - }; - - return ; + return ( + + ); }, }, { diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss index c8678711e4..33859efd1d 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss @@ -13,25 +13,17 @@ .data-table__table { thead { .name__header { - width: $col-md; + width: initial; // Allows name to take up as much space on table as it can before truncating } .role__header { - width: $col-md; - } - - .actions__header { - width: auto; + width: $col-sm; } } tbody { - .name__cell { - max-width: $col-md; - } - .role__cell { - max-width: $col-md; + max-width: $col-sm; } .actions__cell { diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/_styles.scss b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/_styles.scss index 77562956e0..c97ec284e8 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/_styles.scss +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/_styles.scss @@ -26,6 +26,7 @@ border-radius: 30px; margin-left: $pad-medium; padding: 4px 12px; + white-space: nowrap; } .react-tabs__tab-list { @@ -39,4 +40,9 @@ padding: 0; } } + + .user-name-text { + text-overflow: ellipsis; + overflow: hidden; + } } diff --git a/frontend/pages/admin/TeamManagementPage/_styles.scss b/frontend/pages/admin/TeamManagementPage/_styles.scss index 32186ca753..e5f747039a 100644 --- a/frontend/pages/admin/TeamManagementPage/_styles.scss +++ b/frontend/pages/admin/TeamManagementPage/_styles.scss @@ -17,8 +17,16 @@ } tbody { - .name__cell { - width: auto; + // Override data-table td max-width at low widths to fit screen + @media (max-width: ($break-md - 1)) { + .name__cell { + max-width: 430px; + } + } + @media (max-width: ($break-sm - 1)) { + .name__cell { + max-width: 330px; + } } .host_count__cell, diff --git a/frontend/pages/admin/UserManagementPage/_styles.scss b/frontend/pages/admin/UserManagementPage/_styles.scss index ae621f5b22..02a385a8cf 100644 --- a/frontend/pages/admin/UserManagementPage/_styles.scss +++ b/frontend/pages/admin/UserManagementPage/_styles.scss @@ -3,104 +3,108 @@ @include grey-badge; } - thead { - // need specificity to override datatable css - th { - &.actions__header { - padding-left: 0; - } - &.status__header, - &.role__header { - width: 86px; // set to prevent expanding - } - } - } - - tbody { - // need specificity to override datatable css - td.name__cell, - td.role__cell, - td.teams__cell, - td.status__cell, - td.email__cell { - max-width: $col-sm; - white-space: nowrap; - } - - td.status__cell, - td.role__cell { - white-space: nowrap; // Prevent No access from wrapping - } - - td.actions__cell { - padding-left: 0; - } - } - - @media (min-width: ($break-lg)) { - .name__header, - .name__cell { - max-width: $col-md; - } - } - - @media (max-width: ($break-sm - 1)) { - .email__header, - .email__cell { - display: none; - width: 0; - } - } - - @media (max-width: ($break-xs - 1)) { - .status__header, - .status__cell { - display: none; - width: 0; - } - } - - @media (max-width: ($break-mobile-md - 1)) { - .teams__header, - .teams__cell { - display: none; - width: 0; - } - - // Splits header to 2 lines with user count on the first line - .table-container__header { - align-items: end; - &-left { - flex-direction: column; - width: initial; - align-items: start; + .data-table-block { + .data-table__table { + thead { + // need specificity to override datatable css + th { + &.actions__header { + padding-left: 0; + } + &.status__header, + &.role__header { + width: 86px; // set to prevent expanding + } + } } - .table-container__search { - width: 100%; + tbody { + // need specificity to override datatable css + td.name__cell, + td.role__cell, + td.teams__cell, + td.status__cell, + td.email__cell { + max-width: $col-sm; + white-space: nowrap; + } + + td.status__cell, + td.role__cell { + white-space: nowrap; // Prevent No access from wrapping + } + + td.actions__cell { + padding-left: 0; + } } - } - } - @media (max-width: ($break-mobile-sm - 1)) { - .role__header, - .role__cell { - display: none; - width: 0; - } + @media (min-width: ($break-lg)) { + .name__header, + .name__cell { + max-width: $col-md; + } + } - // Splits header to 3 lines; user count, wide add user button, wide search - .table-container__header { - flex-direction: column; - align-items: start; - width: 100%; + @media (max-width: ($break-sm - 1)) { + .email__header, + .email__cell { + display: none; + width: 0; + } + } - &-left { - width: 100%; + @media (max-width: ($break-xs - 1)) { + .status__header, + .status__cell { + display: none; + width: 0; + } + } - .controls, - .button { + @media (max-width: ($break-mobile-md - 1)) { + .teams__header, + .teams__cell { + display: none; + width: 0; + } + + // Splits header to 2 lines with user count on the first line + .table-container__header { + align-items: end; + &-left { + flex-direction: column; + width: initial; + align-items: start; + } + + .table-container__search { + width: 100%; + } + } + } + + @media (max-width: ($break-mobile-sm - 1)) { + .role__header, + .role__cell { + display: none; + width: 0; + } + + // Splits header to 3 lines; user count, wide add user button, wide search + .table-container__header { + flex-direction: column; + align-items: start; width: 100%; + + &-left { + width: 100%; + + .controls, + .button { + width: 100%; + } + } } } } diff --git a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTableConfig.tsx b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTableConfig.tsx index 6736927fb6..597d8d2fb8 100644 --- a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTableConfig.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTableConfig.tsx @@ -1,17 +1,16 @@ import React from "react"; -import ReactTooltip from "react-tooltip"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; import StatusIndicator from "components/StatusIndicator"; import TextCell from "components/TableContainer/DataTable/TextCell/TextCell"; -import CustomLink from "components/CustomLink"; +import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell"; import TooltipWrapper from "components/TooltipWrapper"; import { IInvite } from "interfaces/invite"; import { IUser, UserRole } from "interfaces/user"; import { IDropdownOption } from "interfaces/dropdownOption"; import { generateRole, generateTeam, greyCell } from "utilities/helpers"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; -import { COLORS } from "styles/var/colors"; +import { renderApiUserIndicator } from "pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig"; import ActionsDropdown from "../../../../../components/ActionsDropdown"; interface IHeaderProps { @@ -75,52 +74,17 @@ const generateTableHeaders = ( disableSortBy: true, accessor: "name", Cell: (cellProps: ICellProps) => { - const formatter = (val: string) => { - const apiOnlyUser = - "api_only" in cellProps.row.original - ? cellProps.row.original.api_only - : false; + const apiOnlyUser = + "api_only" in cellProps.row.original + ? cellProps.row.original.api_only + : false; - return ( - <> - {val} - {apiOnlyUser && ( - <> - - API - - - <> - This user was created using fleetctl and -
    only has API access.{" "} - - -
    - - )} - - ); - }; - - return ; + return ( + + ); }, }, { diff --git a/frontend/pages/admin/_styles.scss b/frontend/pages/admin/_styles.scss index 5c0624fbeb..10c4aa141c 100644 --- a/frontend/pages/admin/_styles.scss +++ b/frontend/pages/admin/_styles.scss @@ -22,7 +22,7 @@ } } - .component__tabs-wrapper { + .tab-nav { top: $pad-xxlarge; // for sticky z-index: 3; padding-bottom: $pad-xxlarge; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 0a86f4014f..2d35204b42 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -149,13 +149,11 @@ const ManageHostsPage = ({ isOnlyObserver, isPremiumTier, isFreeTier, - isSandboxMode, userSettings, setFilteredHostsPath, setFilteredPoliciesPath, setFilteredQueriesPath, setFilteredSoftwarePath, - setUserSettings, } = useContext(AppContext); const { renderFlash } = useContext(NotificationContext); @@ -1563,7 +1561,6 @@ const ManageHostsPage = ({ variant: "text-icon", iconSvg: "transfer", hideButton: !isPremiumTier || (!isGlobalAdmin && !isGlobalMaintainer), - indicatePremiumFeature: isPremiumTier && isSandboxMode, }, ]; @@ -1704,7 +1701,7 @@ const ManageHostsPage = ({
    {renderHeader()}
    - {!isSandboxMode && canEnrollHosts && !hasErrors && ( + {canEnrollHosts && !hasErrors && (
    )} - + setNavTabIndex(i)}> {NAV_TITLES.RESULTS} - - {errors?.length > 0 && ( - - {errors.length.toLocaleString()} - - )} + {NAV_TITLES.ERRORS} - + {renderResultsTab()} {renderErrorsTab()} - + {showQueryModal && ( - queryId - ? router.push(PATHS.EDIT_QUERY(queryId, currentTeamId)) - : router.push(PATHS.NEW_QUERY(currentTeamId)), - [] - ); + const goToQueryEditor = useCallback(() => { + const path = queryId ? PATHS.EDIT_QUERY(queryId) : PATHS.NEW_QUERY; + + router.push(getPathWithQueryParams(path, { team_id: currentTeamId })); + }, []); const renderScreen = () => { const step1Props = { diff --git a/frontend/router/page_titles.ts b/frontend/router/page_titles.ts index 0b53cb8f42..ec6c89e0aa 100644 --- a/frontend/router/page_titles.ts +++ b/frontend/router/page_titles.ts @@ -19,7 +19,7 @@ export default [ path: PATHS.MANAGE_QUERIES, title: `Queries | ${DOCUMENT_TITLE_SUFFIX}`, }, - { path: PATHS.NEW_QUERY(), title: `New query | ${DOCUMENT_TITLE_SUFFIX}` }, + { path: PATHS.NEW_QUERY, title: `New query | ${DOCUMENT_TITLE_SUFFIX}` }, { path: PATHS.MANAGE_POLICIES, title: `Policies | ${DOCUMENT_TITLE_SUFFIX}`, diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index f7093ac7a9..8683447023 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -1,6 +1,5 @@ import { buildQueryStringFromParams } from "utilities/url"; -import { IPolicy } from "../interfaces/policy"; import URL_PREFIX from "./url_prefix"; // Note: changes to paths.ts should change page_titles.ts respectively @@ -97,39 +96,16 @@ export default { EDIT_PACK: (packId: number): string => { return `${URL_PREFIX}/packs/${packId}/edit`; }, - PACK: (packId: number): string => { - return `${URL_PREFIX}/packs/${packId}`; - }, - EDIT_LABEL: (labelId: number): string => { - return `${URL_PREFIX}/labels/${labelId}`; - }, - EDIT_QUERY: (queryId: number, teamId?: number): string => { - return `${URL_PREFIX}/queries/${queryId}/edit${ - teamId ? `?team_id=${teamId}` : "" - }`; - }, - LIVE_QUERY: ( - queryId: number | null, - teamId?: number, - hostId?: number - ): string => { - const baseUrl = `${URL_PREFIX}/queries/${queryId || "new"}/live`; - const queryParams = buildQueryStringFromParams({ - team_id: teamId, - host_id: hostId, - }); - return queryParams ? `${baseUrl}?${queryParams}` : baseUrl; - }, - QUERY_DETAILS: (queryId: number, teamId?: number): string => { - return `${URL_PREFIX}/queries/${queryId}${ - teamId ? `?team_id=${teamId}` : "" - }`; - }, - EDIT_POLICY: (policy: IPolicy): string => { - return `${URL_PREFIX}/policies/${policy.id}${ - policy.team_id !== undefined ? `?team_id=${policy.team_id}` : "" - }`; - }, + PACK: (packId: number): string => `${URL_PREFIX}/packs/${packId}`, + EDIT_LABEL: (labelId: number): string => `${URL_PREFIX}/labels/${labelId}`, + EDIT_QUERY: (queryId: number): string => + `${URL_PREFIX}/queries/${queryId}/edit`, + LIVE_QUERY: (queryId: number | null): string => + `${URL_PREFIX}/queries/${queryId || "new"}/live`, + QUERY_DETAILS: (queryId: number): string => + `${URL_PREFIX}/queries/${queryId}`, + EDIT_POLICY: (policyId: number): string => + `${URL_PREFIX}/policies/${policyId}`, FORGOT_PASSWORD: `${URL_PREFIX}/login/forgot`, MFA: `${URL_PREFIX}/login/mfa`, NO_ACCESS: `${URL_PREFIX}/login/denied`, @@ -143,7 +119,6 @@ export default { LOGIN: `${URL_PREFIX}/login`, LOGOUT: `${URL_PREFIX}/logout`, MANAGE_HOSTS: `${URL_PREFIX}/hosts/manage`, - MANAGE_HOSTS_ADD_HOSTS: `${URL_PREFIX}/hosts/manage/?add_hosts=true`, MANAGE_HOSTS_LABEL: (labelId: number | string): string => { return `${URL_PREFIX}/hosts/manage/labels/${labelId}`; }, @@ -199,14 +174,10 @@ export default { NEW_PACK: `${URL_PREFIX}/packs/new`, MANAGE_QUERIES: `${URL_PREFIX}/queries/manage`, MANAGE_SCHEDULE: `${URL_PREFIX}/schedule/manage`, - MANAGE_TEAM_SCHEDULE: (teamId: number): string => { - return `${URL_PREFIX}/schedule/manage?team_id=${teamId}`; - }, MANAGE_POLICIES: `${URL_PREFIX}/policies/manage`, NEW_LABEL: `${URL_PREFIX}/labels/new`, NEW_POLICY: `${URL_PREFIX}/policies/new`, - NEW_QUERY: (teamId?: number) => - `${URL_PREFIX}/queries/new${teamId ? `?team_id=${teamId}` : ""}`, + NEW_QUERY: `${URL_PREFIX}/queries/new`, RESET_PASSWORD: `${URL_PREFIX}/login/reset`, SETUP: `${URL_PREFIX}/setup`, ACCOUNT: `${URL_PREFIX}/account`, diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index dc85d38b47..fe04315e41 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -86,18 +86,6 @@ export const addGravatarUrlToResource = (resource: any): any => { }; }; -export const createHostsByPolicyPath = ( - policyId: number, - policyResponse: PolicyResponse, - teamId?: number | null -) => { - return `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({ - policy_id: policyId, - policy_response: policyResponse, - team_id: teamId, - })}`; -}; - /** Removes Apple OS Prefix from host.os_version. */ export const removeOSPrefix = (version: string): string => { return version.replace(/^(macOS |iOS |iPadOS )/i, ""); @@ -855,17 +843,6 @@ export const getSoftwareBundleTooltipJSX = (bundle: string) => ( ); -export const TAGGED_TEMPLATES = { - queryByHostRoute: (hostId?: number | null, teamId?: number | null) => { - const queryString = buildQueryStringFromParams({ - host_id: hostId || undefined, - team_id: teamId, - }); - - return queryString && `?${queryString}`; - }, -}; - export const internallyTruncateText = ( original: string, prefixLength = 280, @@ -924,7 +901,6 @@ export default { addGravatarUrlToResource, removeOSPrefix, compareVersions, - createHostsByPolicyPath, formatLabelResponse, formatFloatAsPercentage, formatSeverity, @@ -963,5 +939,4 @@ export default { normalizeEmptyValues, wait, wrapFleetHelper, - TAGGED_TEMPLATES, }; diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index c15a910a98..a3002c84c4 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -92,6 +92,7 @@ const filterEmptyParams = (queryParams: QueryParams) => { * creates a query string from a query params object. If a value is undefined, null, * or an empty string on the queryParams object, that key-value pair will be * excluded from the query string. + * TODO: For UI elements, replace all instances of buildQueryStringFromParams with getPathWithQueryParams */ export const buildQueryStringFromParams = (queryParams: QueryParams2) => { const filteredParams = filterEmptyParams(queryParams); @@ -107,6 +108,24 @@ export const buildQueryStringFromParams = (queryParams: QueryParams2) => { return queryString; }; +/** + * creates a path string from the root path and optional query params + * @param endpoint + * @param queryParams + * @returns string + */ +export const getPathWithQueryParams = ( + endpoint: string, + queryParams?: QueryParams2 +) => { + if (!queryParams) { + return endpoint; + } + + const queryString = buildQueryStringFromParams(queryParams); + return queryString ? `${endpoint}?${queryString}` : endpoint; +}; + export const reconcileSoftwareParams = ({ teamId, softwareId, diff --git a/frontend/utilities/url/url.tests.ts b/frontend/utilities/url/url.tests.ts index cdf84fa5f8..18e71b3a08 100644 --- a/frontend/utilities/url/url.tests.ts +++ b/frontend/utilities/url/url.tests.ts @@ -1,8 +1,91 @@ import { buildQueryStringFromParams, + getPathWithQueryParams, reconcileMutuallyInclusiveHostParams, } from "."; +describe("url utilites > buildQueryStringFromParams", () => { + it("creates a query string from a params object", () => { + const params = { + query: "test", + page: 1, + order: "asc", + isNew: true, + }; + expect(buildQueryStringFromParams(params)).toBe( + "query=test&page=1&order=asc&isNew=true" + ); + }); + + it("filters out undefined values", () => { + const params = { + query: undefined, + page: 1, + order: "asc", + }; + expect(buildQueryStringFromParams(params)).toBe("page=1&order=asc"); + }); + + it("filters out empty string values", () => { + const params = { + query: "", + page: 1, + order: "asc", + }; + expect(buildQueryStringFromParams(params)).toBe("page=1&order=asc"); + }); + + it("filters out null values", () => { + const params = { + query: null, + page: 1, + order: "asc", + }; + expect(buildQueryStringFromParams(params)).toBe("page=1&order=asc"); + }); +}); + +describe("url utilities > getPathWithQueryParams", () => { + it("returns the endpoint when no query params are provided", () => { + expect(getPathWithQueryParams("/api/users")).toBe("/api/users"); + }); + + it("appends query string when query params are provided", () => { + const endpoint = "/hosts/manage"; + const queryParams = { + software_id: 25, + team_id: 10, + order_key: "issues", + }; + expect(getPathWithQueryParams(endpoint, queryParams)).toBe( + "/hosts/manage?software_id=25&team_id=10&order_key=issues" + ); + }); + + it("filters out undefined, null, and empty string values from query params", () => { + const endpoint = "/hosts/manage"; + const queryParams = { + software_id: undefined, + team_id: null, + policy_response: "", + policy_id: 4, + }; + expect(getPathWithQueryParams(endpoint, queryParams)).toBe( + "/hosts/manage?policy_id=4" + ); + }); + + it("returns only the endpoint when all query params are filtered out", () => { + const endpoint = "/hosts/manage"; + const queryParams = { + software_id: undefined, + team_id: null, + policy_response: "", + }; + expect(getPathWithQueryParams(endpoint, queryParams)).toBe("/hosts/manage"); + }); +}); + describe("url utilities > reconcileMutuallyInclusiveHostParams", () => { it("leaves macSettingsStatus and teamId unchanged when both are present", () => { const [macSettingsStatus, teamId] = ["pending" as const, 1]; @@ -75,44 +158,3 @@ describe("url utilities > reconcileMutuallyInclusiveHostParams", () => { }); }); }); - -describe("url utilites > buildQueryStringFromParams", () => { - it("creates a query string from a params object", () => { - const params = { - query: "test", - page: 1, - order: "asc", - isNew: true, - }; - expect(buildQueryStringFromParams(params)).toBe( - "query=test&page=1&order=asc&isNew=true" - ); - }); - - it("filters out undefined values", () => { - const params = { - query: undefined, - page: 1, - order: "asc", - }; - expect(buildQueryStringFromParams(params)).toBe("page=1&order=asc"); - }); - - it("filters out empty string values", () => { - const params = { - query: "", - page: 1, - order: "asc", - }; - expect(buildQueryStringFromParams(params)).toBe("page=1&order=asc"); - }); - - it("filters out null values", () => { - const params = { - query: null, - page: 1, - order: "asc", - }; - expect(buildQueryStringFromParams(params)).toBe("page=1&order=asc"); - }); -}); diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index e4e17fc68a..d17954f534 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -709,7 +709,7 @@ Here are some tips for making this meeting effective: ### User story reviews -User story reviews [happen weekly](https://fleetdm.com/handbook/product-design#rituals) between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team) and the each product group's Product Designer, Engineering Manager (EM), and Quality Assurance (QA) Engineer. During the call, the Product Designer presents all user stories that have completed product design in the past week and are in the "In review" column. The Product Designer is the DRI for completing all product checklist items before bringing to review. +User story reviews [happen weekly](https://fleetdm.com/handbook/product-design#rituals) between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team) and each product group's Product Designer (PD), Engineering Manager (EM), and Quality Assurance (QA) Engineer. During the call, contributors (PD and EM) present all user stories that are in the "In review" column. The PD is the DRI for completing all product checklist items before bringing to review. For [engineer-initiated stories](https://fleetdm.com/handbook/engineering#create-an-engineering-initiated-story), the EM is the DRI for completing all engineering checklist items before bringing to review. The purpose of the review is to familiarize the EM and QA Engineer with the user story, and provide an opportunity to ask questions, clarify requirements, and highlight potential implementation issues. The first draft of the test plan produced by the Product Designer is reviewed and revised as needed during the call. The QA Engineer is the DRI for finalizing the test plan. diff --git a/proposals/001-controlled-rollout.md b/proposals/001-controlled-rollout.md deleted file mode 100644 index a970297a8c..0000000000 --- a/proposals/001-controlled-rollout.md +++ /dev/null @@ -1,115 +0,0 @@ -# Controlled Rollout proposal - -## Why - -New features are great, everybody loves them. However, new features come by the hand of new code. New code can have bugs -or it can have performance regressions. - -Features aren't perfect for all users. Sometimes they are perfect for some users and not others. Sometimes they are -perfect for some hosts and not others. - -Rolling out a feature shouldn't always be a binary choice: enabled/disabled. In an ideal world, all features would be -enabled by default, everybody would love them all, and they would work flawlessly for all possible use cases. - -We are in the real world, though, which is not ideal. So we should give people running Fleet tools to rollout features -slowly, so that they can update infrastructure if needed, or only use a feature within the scope that is useful for -them. - -This is a proposal on how this tool could look like and work. - -## How - -We would create a new type of boolean value in our `AppConfig` called `RolloutBoolean`. - -`RolloutBoolean` will have a function `Get(h *fleet.Host) bool`. So instead of doing this: - -```go -if ac.HostSettings.EnableHostUsers { - ... -} -``` - -We would do: - -```go -if ac.HostSettings.EnableHostUsers.Get(host) { - ... -} -``` - -In yaml terms, this is what `RolloutBoolean` would be able to parse: - -1. Regular true/false, 0/1, yes/no values - -```yaml ---- -apiVersion: v1 -kind: config -spec: - host_settings: - enable_software_inventory: false -``` - -2. Only enable a feature for certain teams: - -```yaml ---- -apiVersion: v1 -kind: config -spec: - host_settings: - enable_software_inventory: - default: false - overrides: - teams: - - team1: true - - team2: true -``` - -2. Enabled for all except a specified team: - -```yaml ---- -apiVersion: v1 -kind: config -spec: - host_settings: - enable_software_inventory: - default: true - overrides: - teams: - - team1: false -``` - -3. Enabled only for specific hosts: - -```yaml ---- -apiVersion: v1 -kind: config -spec: - host_settings: - enable_software_inventory: - default: false - overrides: - host_ids: - - 3214: true -``` - -4. Enabled for hosts on a specific platform (as reported by osquery, not in terms of label membership): - -```yaml ---- -apiVersion: v1 -kind: config -spec: - host_settings: - enable_software_inventory: - default: false - overrides: - platforms: - - linux: true -``` - -The `Get(h *Host) bool` function will use the provided host to define whether the feature is enabled or not based on -how it's defined in the configuration. \ No newline at end of file diff --git a/proposals/Fleet-Windows-OS-vulns.md b/proposals/Fleet-Windows-OS-vulns.md deleted file mode 100644 index 3c85a6b70e..0000000000 --- a/proposals/Fleet-Windows-OS-vulns.md +++ /dev/null @@ -1,139 +0,0 @@ -# Detecting Windows OS vulnerabilities - -The first step in detecting Windows vulnerabilities is understanding how they are remediated. Using -the CISA list of know vulnerabilities as a sample, it was determined that all vulnerability -remediations follow the form of *applying some software patch* and then *following some -steps (if any)* so it follows that to determine if a system is susceptible to a vulnerability we will need to -check whether a specific software patch was applied and also if the provided steps were followed. So -basically we have three problems we need to solve: -1. We need a list of vulnerabilities + remediations. Each remediation is composed of a patch + some steps. -2. For a given vulnerability we need to determine whether the proper patch was applied. -3. For a given vulnerability we need to determine whether the steps (if any) were followed. - -## List of vulnerabilities - -To get the list of vulnerabilities we can use the [Microsoft Security Updates API](https://api.msrc.microsoft.com/cvrf/v2.0/swagger/index). This [endpoint](api.msrc.microsoft.com/cvrf/v2.0/document/yyyy-mm) in particular exposes all the security updates released for yyyy-mmm formatted according to the [Common Vulnerability Reporting Framework](http://docs.oasis-open.org/csaf/csaf-cvrf/v1.2/csaf-cvrf-v1.2.html#:~:text=The%20CSAF%20Common%20Vulnerability%20Reporting,impact%20and%20remediation%20among%20interested) (cvrf) format, this cvrf document will include entries for vulnerabilities, how to patch them and what products are affected. - -For example, say we have a host running *Windows 10 Version 21H2 x64* and we want to determine whether that host is susceptible to *CVE-2022-26925*. Looking at https://api.msrc.microsoft.com/cvrf/v2.0/document/2022-May we can see that *Windows 10 Version 21H2 for x64* is assigned the Product ID **11931**: - -``` -... - - ... - - ... - Windows 10 Version 21H2 for x64-based Systems - ... - - … - -… -``` - -Looking at the matching vulnerability element (``) *CVE-2022-26925*, we can see that -*Windows 10 Version 21H2 x64* is indeed affected by it: - -``` - - - ... - 11931 - ... - - -``` - -And also the remediation steps: - -``` - -… - - 5013942 - https://catalog.update.microsoft.com/v7/site/Search.aspx?q=KB5013942 - 5012599 - .. - 11931 - ... - Yes - … - - - 5013942 - https://support.microsoft.com/help/5013942 - ... - 11931 - - -``` - -For the previous extract we can determine four things: -1. The vulnerability was patched (‘Vendor Fix’) on the **KB5013942** update. -2. **KB5013942** supersedes **KB5012599** (this is useful for handling cumulative updates). -3. After applying **KB5013942** the system will need to be restarted (which can be viewed as an extra step). -4. There is also some more info about the vulnerability that might include some extra steps to follow (‘Known Issue’). - - -## Determine whether the proper patch was applied - -Luckily for us [osquery 5.4](https://github.com/osquery/osquery/pull/7407) will include a new table -that exposes windows updates, so we won’t need to do much on the osquery data collection side of -things. Here’s what I get when selecting all from the new table on my Windows test machine (Windows 10 Version 21H2 x64): - -| client_app_id | date | description | hresult | operation | result_code | server_selection | service_id | support_url | title | update_id | update_revision | -|---------------------------------------------------------------------|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|--------------|-------------|------------------|--------------------------------------|------------------------------------------------|------------------------------------------------------------------------------------------------------------------|--------------------------------------|-----------------| -| MoUpdateOrchestrator | 1658271402 | Install this update to revise the files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed. | 0 | Installation | Succeeded | WindowsUpdate | | https://go.microsoft.com/fwlink/?LinkId=52661 | Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.371.442.0) | 688fe8b8-e59d-44c8-b083-7ab25a4317f4 | 200 | -| MoUpdateOrchestrator | 1658270728 | Install this update to resolve issues in Windows. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article for more information. After you install this item, you may have to restart your computer. | -2145116140 | Installation | InProgress | Others | 8b24b027-1dee-babb-9a95-3517dfb9c552 | https://support.microsoft.com/help/5015807 | 2022-07 Cumulative Update for Windows 10 Version 21H2 for x64-based Systems (KB5015807) | 3a328459-dd2c-4af7-97db-8424da0d3e72 | 1 | -| MoUpdateOrchestrator | 1658230218 | Install this update to resolve issues in Windows. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article for more information. After you install this item, you may have to restart your computer. | -2147024784 | Installation | Failed | Others | 8b24b027-1dee-babb-9a95-3517dfb9c552 | https://support.microsoft.com/help/5015807 | 2022-07 Cumulative Update for Windows 10 Version 21H2 for x64-based Systems (KB5015807) | 3a328459-dd2c-4af7-97db-8424da0d3e72 | 1 | -| Windows Defender | 1658228495 | Install this update to revise the files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed. | 0 | Installation | Succeeded | WindowsUpdate | | https://go.microsoft.com/fwlink/?LinkId=52661 | Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.371.415.0) | a0620c31-004f-4e4f-a15c-5172a3d2f3a6 | 200 | -| MoUpdateOrchestrator | 1658226954 | After the download, this tool runs one time to check your computer for infection by specific, prevalent malicious software (including Blaster, Sasser, and Mydoom) and helps remove any infection that is found. If an infection is found, the tool will display a status report the next time that you start your computer. A new version of the tool will be offered every month. If you want to manually run the tool on your computer, you can download a copy from the Microsoft Download Center, or you can run an online version from microsoft.com. This tool is not a replacement for an antivirus product. To help protect your computer, you should use an antivirus product. | 0 | Installation | Succeeded | WindowsUpdate | | http://support.microsoft.com | Windows Malicious Software Removal Tool x64 - v5.103 (KB890830) | 675d532b-cdd5-4f87-a918-72af430c86a9 | 200 | -| MoUpdateOrchestrator | 1658226800 | After the download, this tool runs one time to check your computer for infection by specific, prevalent malicious software (including Blaster, Sasser, and Mydoom) and helps remove any infection that is found. If an infection is found, the tool will display a status report the next time that you start your computer. A new version of the tool will be offered every month. If you want to manually run the tool on your computer, you can download a copy from the Microsoft Download Center, or you can run an online version from microsoft.com. This tool is not a replacement for an antivirus product. To help protect your computer, you should use an antivirus product. | -2145124341 | Installation | Aborted | WindowsUpdate | | http://support.microsoft.com | Windows Malicious Software Removal Tool x64 - v5.103 (KB890830) | 675d532b-cdd5-4f87-a918-72af430c86a9 | 200 | -| MoUpdateOrchestrator | 1658225364 | Install this update to resolve issues in Windows. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article for more information. After you install this item, you may have to restart your computer. | 0 | Installation | Succeeded | WindowsUpdate | | http://support.microsoft.com | 2022-06 Cumulative Update for .NET Framework 3.5 and 4.8 for Windows 10 Version 21H2 for x64 (KB5013887) | 17b120c7-57a2-47d3-9128-3b8fa9a22c42 | 200 | -| MoUpdateOrchestrator | 1658225234 | Install this update to resolve issues in Windows. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article for more information. After you install this item, you may have to restart your computer. | -2145124341 | Installation | Aborted | WindowsUpdate | | http://support.microsoft.com | 2022-06 Cumulative Update for .NET Framework 3.5 and 4.8 for Windows 10 Version 21H2 for x64 (KB5013887) | 17b120c7-57a2-47d3-9128-3b8fa9a22c42 | 200 | -| MoUpdateOrchestrator | 1658225225 | A security issue has been identified in a Microsoft software product that could affect your system. You can help protect your system by installing this update from Microsoft. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article. After you install this update, you may have to restart your system. | 0 | Installation | Succeeded | WindowsUpdate | | http://support.microsoft.com | 2022-04 Update for Windows 10 Version 21H2 for x64-based Systems (KB5005463) | 9151c073-854c-474e-8e4c-3b7b067824b1 | 200 | -| MoUpdateOrchestrator | 1658225201 | A security issue has been identified in a Microsoft software product that could affect your system. You can help protect your system by installing this update from Microsoft. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article. After you install this update, you may have to restart your system. | -2145124341 | Installation | Aborted | WindowsUpdate | | http://support.microsoft.com | 2022-04 Update for Windows 10 Version 21H2 for x64-based Systems (KB5005463) | 9151c073-854c-474e-8e4c-3b7b067824b1 | 200 | -| MoUpdateOrchestrator | 1658224963 | Install this update to resolve issues in Windows. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article for more information. After you install this item, you may have to restart your computer. | 0 | Installation | Succeeded | WindowsUpdate | | http://support.microsoft.com | 2022-02 Cumulative Update Preview for .NET Framework 3.5 and 4.8 for Windows 10 Version 21H2 for x64 (KB5010472) | 89858baf-a5ff-4c7e-b81b-037c0c17155a | 200 | -| MoUpdateOrchestrator | 1658224904 | Install this update to resolve issues in Windows. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article for more information. After you install this item, you may have to restart your computer. | -2145124341 | Installation | Aborted | WindowsUpdate | | http://support.microsoft.com | 2022-02 Cumulative Update Preview for .NET Framework 3.5 and 4.8 for Windows 10 Version 21H2 for x64 (KB5010472) | 89858baf-a5ff-4c7e-b81b-037c0c17155a | 200 | -| MoUpdateOrchestrator | 1658224899 | A security issue has been identified in a Microsoft software product that could affect your system. You can help protect your system by installing this update from Microsoft. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article. After you install this update, you may have to restart your system. | -2145116140 | Installation | InProgress | Others | 8b24b027-1dee-babb-9a95-3517dfb9c552 | https://support.microsoft.com/help/4023057 | 2022-04 Update for Windows 10 Version 21H2 for x64-based Systems (KB4023057) | a329b681-ce8c-431d-99f7-052e2901adcb | 1 | -| MoUpdateOrchestrator | 1658224892 | Install this update to resolve issues in Windows. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article for more information. After you install this item, you may have to restart your computer. | -2145124341 | Installation | Aborted | WindowsUpdate | | http://support.microsoft.com | 2022-02 Cumulative Update Preview for .NET Framework 3.5 and 4.8 for Windows 10 Version 21H2 for x64 (KB5010472) | 89858baf-a5ff-4c7e-b81b-037c0c17155a | 200 | -| Windows Defender | 1658222131 | Install this update to revise the files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed. | 0 | Installation | Succeeded | WindowsUpdate | | https://go.microsoft.com/fwlink/?LinkId=52661 | Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.371.400.0) | e73cc969-7675-4d5f-a635-0dd5511a266b | 200 | -| Update;ScanForUpdates | 1658190023 | 9NBLGGH3FRZM-1152921505694106457 | -2145124300 | Installation | Failed | Others | 855e8a7c-ecb4-4ca3-b045-1dfa50104289 | | 9NBLGGH3FRZM-Microsoft.VCLibs.140.00 | d82f41c1-893a-4a90-ac94-8f83da52a274 | 1 | -| MoUpdateOrchestrator | 1658189063 | Install this update to revise the files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed. | 0 | Installation | Succeeded | WindowsUpdate | | https://go.microsoft.com/fwlink/?LinkId=52661 | Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.371.376.0) | 2b325cd4-4ff1-4ba2-aca7-8a2cb19e4633 | 200 | -| MoUpdateOrchestrator | 1658185542 | Install this update to revise the files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed. | 0 | Installation | Succeeded | WindowsUpdate | | https://go.microsoft.com/fwlink/?LinkId=52661 | Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.371.386.0) | 821547ac-9c27-4397-93bc-d51f0aeb2059 | 200 | -| Microsoft Defender Antivirus (77BDAF73-B396-481F-9042-AD358843EC24) | 1657929544 | This package will update Microsoft Defender Antivirus antimalware platformΓÇÖs components on the user machine. | 0 | Installation | Succeeded | WindowsUpdate | | https://go.microsoft.com/fwlink/?linkid=862339 | Update for Microsoft Defender Antivirus antimalware platform - KB4052623 (Version 4.18.2205.7) | 2dd6d08b-6754-46b5-bfc8-cde6ad24152f | 200 | -| MoUpdateOrchestrator | 1657929207 | Install this update to revise the files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed. | 0 | Installation | Succeeded | WindowsUpdate | | https://go.microsoft.com/fwlink/?LinkId=52661 | Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.371.203.0) | 8325b53b-d4a4-4459-849f-5892c92404ae | 200 | - - -While playing around with this, I did notice what seems to be a bug. There are two entries for **KB5015807**: one marked as Failed which is true, because the first time I tried to install this patch it failed and the other marked as InProgress which is false because the patch was installed correctly. - -We can store all the props if we want, but I would say that the two most important ones are: - -- date: To determine when the patch was applied. -- result_code: To determine whether the update was applied or not. -- title: To extract the update id. - -The major complication here will be dealing with the fact that windows updates are cumulative. -Looking back at our previous example, given the entries in the windows_updates_history table, we -want to determine whether the system is susceptible to *CVE-2022-26925*, we know that vulnerability -was patched in **KB5013942** alas, there are no entries matching **KB5013942** in the -*windows_updates_history* table but, if we look at the security updates for the next month -https://api.msrc.microsoft.com/cvrf/v2.0/document/2022-Jun we can see that **KB5013942** was -superseded by **KB5014699** and then if we look at the security updates for the following month -https://api.msrc.microsoft.com/cvrf/v2.0/document/2022-Jul we can see that **KB5014699** was in -turn superseded by **KB5015807** which is contained in the windows_updates_history table and thus we can -say that the host is not susceptible to *CVE-2022-26925*. - -Given the graph nature of the data, we might want to consider storing the parsed list of vulnerabilities as graph instead of a relational table as we typically do. - -## Determine whether the steps (if any) were followed - -This is the hardest part of the problem and probably the bit we might not be able to solve at the -moment. Not all vulnerability remediations include extra steps, but if they do, they are written in -natural language as a -series of steps intended to be followed by a human operator not as a series of -declarative statements about the system state (like the OVAL definitions we use for detecting -vulnerabilities in Linux). - -AFAIK the only step we will be able to check is whether the system was restarted after a patch was -applied, other types of steps will require either some kind of natural language processing or having -a human in the middle translating the steps into a parsable format. \ No newline at end of file diff --git a/proposals/Fleetctl-Docker-Image.md b/proposals/Fleetctl-Docker-Image.md deleted file mode 100644 index d5f462aa45..0000000000 --- a/proposals/Fleetctl-Docker-Image.md +++ /dev/null @@ -1,51 +0,0 @@ -# Goal - -We need `fleetctl package` functionality to generate all types of packages (PKG, MSI, DEB and RPM) from Linux. - -# How - -Create a new Docker image `fleetdm/fleetctl` that will contain `fleetctl` and all the dependencies ready to create packages. - -Users can then use the image to generate packages -```sh -$ docker run ... fleetdm/fleetctl:latest package --type={pkg|msi|deb|rpm} ... -``` - -## DEB and RPM - -DEB and RPM package generation is already native and no extra dependencies are required (uses https://github.com/goreleaser/nfpm). - -## MSI - -### Packaging - -We will need the same dependencies from `fleetdm/wix:latest` on the new `fleetdm/fleetctl:latest` image. - -### Signing (stretch goal) - -For `.msi` signing functionality: -- The [relic](https://github.com/sassoftware/relic) tool seems to allow `.msi` signing (in Pure Go). -- Alternatively, the [osslsigncode](https://github.com/mtrojnar/osslsigncode) tool could be embedded on the image. - -This is mentioned as a stretch goal because we currently don't have `.msi` signing functionality in `fleetctl package`. - -## PKG - -### Packaging - -To generate a `.pkg` we will need the same dependencies from `fleetdm/bomutils:latest` on the new `fleetdm/fleetctl:latest` image. - -### Signing - -The [relic](https://github.com/sassoftware/relic) tool seems to allow `.pkg` signing (in Pure Go). - -### Notarization - -#### Upload - -We can implement a Go package that uses the new [Notary API](https://developer.apple.com/documentation/notaryapi) to upload and notarize a `.pkg` (pure Go solution). - -#### No Stapling - -The Notary API currently does not offer a way to "staple" a package, and the `stapler` tool that allows this is only available on macOS. -It seems stapling is recommended but not a must, see [#116812](https://developer.apple.com/forums/thread/116812). \ No newline at end of file diff --git a/proposals/Fleetctl-trigger.md b/proposals/Fleetctl-trigger.md deleted file mode 100644 index bd50b3028a..0000000000 --- a/proposals/Fleetctl-trigger.md +++ /dev/null @@ -1,95 +0,0 @@ -# Fleetctl trigger - -## Goal - -As a user, I would like to trigger a set of async jobs using `fleetctl`. For example, I’d like to -trigger a vuln scan, or an MDM dep sync. - -The proposed solution to accomplish this goal enables a new CLI command: -`fleetctl trigger --name `. - -## Background - -Currently, the Fleet server uses the `schedule` package to create sets of defined jobs that are run -serially at defined intervals. The initial schedule interval must be specified at the point the -schedule is instantiated via `schedule.New`. Optionally, `schedule. WithConfigReloadInterval` -accepts a reload interval function. If specified, the reload interval function is periodically -called and its return value becomes the new the schedule interval. This mechanism allows the -schedule interval to be modified by user, for example, by changing the app config; however, there no -mechanism currently to trigger async jobs on an ad hoc basis. - -## Proposal - -### New CLI command `fleetctl trigger --name ` -- Upon this command, the CLI client makes a request to a new authenticated endpoint (see below) to - trigger an ad hoc run of the named schedule. - -### New `schedule` option `WithTrigger` -- This option adds a `trigger` channel on the `schedule` struct that will trigger an ad hoc run of - the scheduled jobs. -- The trigger channel for each `schedule` is exposed via a new `schedules` map on the `Service` struct. - -### New endpoint `GET /trigger?name={:name}` -- The request handler first calls `ds.Lock` to check if the named schedule is locked. -- If the named schedule is unlocked, request handler sends a trigger signal on the schedule's - trigger channel and the server responds with status `202 Accepted`. -- If the named schedule is locked (presumably because the schedule is currently running), the server - responds with status `409 Conflict` and includes a message indicating the schedule is currently - locked. It is up to the user to retry. To facilitate retries, the response message could be expanded to - include additional status information, such as the expiration time of the current lock. - -### Schedule locks -- Currently, lock duration is based on the schedule interval. - - Once an instance takes the lock, it will hold the lock for the duration of the interval, even - after it has completed the jobs in the schedule. - - For long-running jobs, the lock may expire before the current instance completes its run, - meaning that it is currently possible for another instance to start an overlapping job. - - If the lockholder instance is terminated or killed, locks are not released, which may frustrate - a user's attempt to configure a shorter schedule interval before the lock held by the dead - instance expires. - -- Under this proposal, locks become more dynamic. - - Current lockholder releases its lock once it finishes running the schedule. - - Graceful shutdown process handles release of locks upon termination signals. Jobs are - preemptable and an interrupt function must be specified for each job, - e.g., `schedule.New(...).WithJob("job_name", jobFn, interruptFn)`. - - As a fallback for cases that can't be handled via graceful shutdown (e.g., `SIGKILL`), the - expiration for a lock is initially set to a relatively short default duration (e.g., 5 minutes). - The expiration is then periodically extended by the current instance so long as scheduled jobs - are running. If the current instance dies without graceful shutdown, the lock will only be held - by the dead instance for a short period. - -### Additional UX considerations -- What are some potential options that would be useful for the `fleetctl trigger` command? - - Request the current status of the the named schedule without triggering a new run. - For example, `--status` could provide the user with the running time of the schedule (this would - require that we expand the `locks` table to include additional timestamp information, such as - lock start time and lock release time). - - Other useful options? - -- What rules should determine when the interval ticker resets? Consider the following cases where - `s.scheduleInterval = 1*time.Hour`: - - The schedule is triggered at 55 minutes into the 1-hour interval and takes 1 minute to complete. - When should the schedule run again? - (a) after 4 minutes; - (b) after 1 hour; - (c) after 1 hour plus 4 minutes; - (d) other - - The schedule is triggered at 55 minutes into the 1-hour interval and takes 11 minutes to complete. - When should the schedule run again? - (a) immediately; - (b) after 1 hour; - (c) after 54 minutes; - (d) other - -- What should be logged? - - Debug log if schedule runtime exceeds schedule interval to aid detection/troubleshooting of - long-running jobs. - - Other useful logs? - - - - - - - diff --git a/proposals/fips/fleet-server-fips.md b/proposals/fips/fleet-server-fips.md deleted file mode 100644 index 66b9d5dfe0..0000000000 --- a/proposals/fips/fleet-server-fips.md +++ /dev/null @@ -1,63 +0,0 @@ -# Planning for a FIPS Compliant Fleet Server for Linux - -This document outlines the steps involved in building a version of the Fleet server that meets the requirements of the Federal Information Processing Standards (FIPS) 140-2 security standard. - -## Summary of cryptographic operations in Fleet - -To make the Fleet server FIPS 140-2 compliant, all cryptographic operations must use a FIPS 140-2 certified cryptographic library. - -Fleet uses cryptographic operations in the following features: -- Fleet uses TLS as a client for the following features: - - Fleet can conect to MySQL and Redis using TLS. - - Vulnerability processing uses TLS client to retrieve data from different sources. - - Automations: Webhooks, JIRA and Zendesk integrations make use of a TLS client. - - Single Sign On uses TLS to retrieve IdP metadata (from the configured MetadataURL). - - MDM functionality connects via TLS to Apple servers (DEP enrollment configuration). - - Fleet uses TLS to stream logs to many data stream systems (Kafka, Lambda, Kinesis, Firehose, etc.). -- Fleet can act as a TLS server (can terminate TLS). -- User authentication: Fleet uses password hashing to store and authenticate user credentials. -- The Single Sign On (SSO) feature in Fleet performs cryptographic verification of `SAMLResponse`s signatures. Fleet uses [goxmldsig](https://github.com/russellhaering/goxmldsig) which uses golang's stdlib crypto (`crypto/x509`, `crypto/rsa`, `crypto/sha256`, `crypto/tls` which are all compliant primitives) -- Fleet uses `crypto/rand` to generate secrets for authentication ("session tokens" and "node keys" which are used for authenticating users and devices respectively). -- MDM functionality (for Windows and macOS) makes use of cryptographic operations (e.g. Fleet acts as a SCEP CA server for authenticating devices) -- Fleet license check code uses `JWT` which uses `ECDSA` for public signatures (ECDSA is a FIPS-compliant primitive). - -All these operations (except password hashing, see [Password hashing](#password-hashing) below) make use of Go's `crypto` standard library. - -## Building Go with BoringSSL as cryptographic backend - -As of today, the recommended way to make your Go application be FIPS 140-2 compliant is to use the BoringSSL crypto backend instead of the standard library `crypto` packages. - -Since we use Go 1.19, we only need to build fleet with `CGO_ENABLED=1` (already the case because of our sqlite3 dependency) and with `GOEXPERIMENT=boringcrypto`. This will automatically make Fleet use the BoringSSL cryptographic primitives instead of the stdlib `crypto` implementation (without requiring any code changes). - -See the [POC]https://github.com/fleetdm/fleet/compare/13288-poc-fleet-fips?expand=1()). - -> Source: https://kupczynski.info/posts/fips-golang/ - -## Password hashing - -For password hashing Fleet currently uses the [bcrypt](https://en.wikipedia.org/wiki/Bcrypt) function. -To be FIPS 140-2 compliant, Fleet will have to use [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2] instead (with SHA-256 as hashing primitive). - -> Source: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf - -## Summary of tasks - -Assumptions around the first customer using the FIPS build: -- All MDM funtionality will be disabled/not-used. -- The Fleet server won't be doing TLS termination. Thus we don't need to verify/test such feature. -- This build will be used as part of a new deployment (not migrating an existing one). This is important because we have to change the password hashing algorithm thus to reduce complexity on the first iteration we don't need to worry about migrating from old to new password hashing. -- The `fleetctl` command is outside the scope. - -Tasks: - -- Double check if any cryptographic operations in Fleet were missed in this analysis. // 1 pt. -- Add a new target `fleet-fips` to the `Makefile` to build Fleet in FIPS mode. (Would set `GOEXPERIMENT=boringcrypto CGO_ENABLED=1`.) Smoke test the Fleet server when built this way. (See the [POC]https://github.com/fleetdm/fleet/compare/13288-poc-fleet-fips?expand=1()) // 1 pt. -- Make changes in goreleaser yamls to create a new docker image fleetdm/fleet-fips // 2 pt. -- Perform full QA of the Feet docker image FIPS build (by full QA we mean: we need to test ALL Fleet features). This should be performed by a QA engineer. // 5 pt. -- Perform any code changes to fleetd (and add documentation for vanilla osquery) to be able to connect (via TLS) to a FIPS-compliant Fleet. We will know if this is necessary from the previous task (QA). // 2 pt. -- Add tests to make sure the Fleet server TLS client is using the FIPS approved ciphers when connecting to TLS servers (SSO, users, vuln processing, webhooks). E.g. by using TLS servers that will fail the connection if the FIPS ciphers are not used. // 3 pt. -- Replace bcrypt with PBKDF2 for password hashing (when compiling in FIPS mode) // 5 pt. (assumming we don't need to migrate from bcrypt) -- Terminate the Fleet sever if MDM or TLS server is enabled when running the FIPS build (security measure). // 1 pt. -- Check if `crypto/subtle` (used by the `/metrics` endpoint for HTTP basic auth) is implemented by BoringSSL. // 1 pt. -- Loadtest the Fleet server with expected number of devices. // 5 pt. (depends on number of hosts) -- Deploy the Fleet FIPS docker image and dependencies to a FIPS enabled AWS endpoint // 5 pt. diff --git a/proposals/fleet-desktop-token-rotation.md b/proposals/fleet-desktop-token-rotation.md deleted file mode 100644 index 40ac3a6429..0000000000 --- a/proposals/fleet-desktop-token-rotation.md +++ /dev/null @@ -1,64 +0,0 @@ -# Token Rotation for Fleet Desktop - -This file is based on the original proposal described at [#6348](https://github.com/fleetdm/fleet/issues/6348) modified based on a few lessons we learned and the new communication channel between Orbit and the Fleet server introduced in https://github.com/fleetdm/fleet/issues/6851 - -**Compatibility** - -| | Fleet Desktop < v1.0.0 | Fleet Destkop >= v1.0.0 | -| -------------- | --------------------------------- | ----------------------- | -| Server < 4.21 | OK/Rotation disabled | OK/Rotation disabled | -| Server >= 4.21 | Fleet Destkop breaks after 1 hour | OK/Rotation enabled | - - -## Fleet Server - -1. Add a new endpoint `POST /orbit//device_token` to create/update device tokens - 1. Add `created_at` and `updated_at` columns to the `host_device_auth` table. - 2. Do `INSERT ON DUPLICATE KEY token=token` and not `update updated_at` if the token didn't change. -1. Condsider a token expired if `now - updated_at > 1h`, APIs will return the usual authentication error when a token is expired. -1. The server doesn't need to make the `orbit_info` in the "extra detail queries" set anymore. - -## Orbit - -1. When Orbit starts - 1. If we have a token, load and verify its validity by making a request to the Fleet Server - 1. If we don't have a token or if the token is invalid, [rotate the token](#token-rotation) - -1. Orbit will have two tickers running: - - **Ticker I**: runs every 5 minutes and verifies that the current token is still valid by making a request to the Fleet Server. This is to guard against the server invalidating the token (eg: DB restored from back-up, token `updated_at` manually changed, clocks out of sync etc.) If the token is invalid, it [starts a rotation](#token-rotation). - - **Ticker II**: runs every 30 seconds and verifies the `mtime` of the identifier file, if `now - mtime > 1h` [starts a rotation](#token-rotation). A short interval (could be even shorter) is needed in case the computer was shut-down or went to sleep. - -**Token Rotation** - -To rotate a token, Orbit will generate a valid UUID and: - -1. Write the value to the `identifier` file, we do this first to signal to Fleet Desktop that a rotation is happening and we can ensure it never operates on an invalid token during the exchange. -2. Do a `POST /orbit//device_token` with the new token value, retry three times in case of failure. - -**Compatibility** - -1. Keep returning `device_auth_token` in the `orbit_info` table, we might want to do this forever anyway to support live queries. -2. When Orbit starts, check if the server supports creating tokens via the API, if it doesn't: - 1. Don't do any kind of check or rotation - 2. Start a goroutine using `oklog.Group` to ping the server every 10 minutes and see if it supports token rotation. If it does, return from the group actor, which will make Orbit restart. -3. Orbit will keep sending the `FLEET_DESKTOP_DEVICE_URL` variable to accomodate old Fleet Destkop versions. - -## Fleet Desktop - -1. Fleet Desktop will receive the path to the identifier file as an environment variable. -2. As soon as Flet Desktop starts, it reads the identifier file and caches the `mtime` value in memory. -3. Fleet Desktop will have a ticker running every ~5 seconds to check for the `mtime` of the identifier file, if the value differs from the one stored in memory: - 1. Disable all tray items and show "Connecting..." - 2. Initialize a ticker to check for the validity of the token, enable the tray items again once we have a valid token. - -**Misc** - -- As soon as any request fails, disable the tray items and show "Connecting..." - -## Release order - -After things have been tested on unstable channels and Dogfood, it's important to release in the following order: - -1. Orbit to the stable channel -2. Fleet Desktop to the stable channel -3. Fleet Server diff --git a/proposals/improv-mac-os-vuln-detection.md b/proposals/improv-mac-os-vuln-detection.md deleted file mode 100644 index 778d4578c4..0000000000 --- a/proposals/improv-mac-os-vuln-detection.md +++ /dev/null @@ -1,117 +0,0 @@ -# Improving vulnerability detection for MacOS - -[6001](https://github.com/fleetdm/fleet/issues/6001) identified some problems with our -current approach to vulnerability detection on MacOS: - -- The version reported by software does not fit the standard format. For example, Zoom reports the version as 5.8.3 (2240). -- The app name includes extra terms that don't appear in the title. For example, zoom.us is treated - as zoom us (2 terms) and does not match the title commonly used for zoom eg "Zoom 4.6.9 for macOS" - or "Zoom Meetings 5.8.0 for macOS". -- Sometimes the CPE dictionary is incomplete. For example, CVE-2021-24043 should have a matching CPE `cpe:2.3:a:whatsapp:whatsapp:2.2145.0:*:*:*:desktop:*:*:*`, but it is absent. Also not that it would not match on windows because target_sw is empty, but we try to match on windows*. Removing the target_sw would lead to many false positives. - -Our current approach to CPE binding consists of matching the `software name` against the `CPE title` along with the `software version`. Instead, I propose we -try to match the software vendor and name parts against the CPE vendor and product parts (standardizing the values when -needed) and then we can programmatically look at the version (and the rest of the CPE parts) to -determine what CVEs match a given CPE. In other words, instead of looking at CPEs as just strings, -we should be looking at them as sets: - -So this: - -``` -cpe:2.3:a:microsoft:edge:79.0.309.68:*:*:*:*:*:*:* -cpe:2.3:a:microsoft:edge:80.0.361.48:*:*:*:*:*:*:* -cpe:2.3:a:microsoft:edge:80.0.361.50:*:*:*:*:*:*:* -cpe:2.3:a:microsoft:edge:80.0.361.50:*:*:*:*:windows:*:* -``` -Can be visualized as this: - -```mermaid -flowchart TD - id1((vendor: microsoft)) --> id2((product: edge)) - id2((product: edge)) --> id3((version: 79.0.309.68)) - id2((product: edge)) --> id4((version: 80.0.361.48)) - id2((product: edge)) --> id5((version: 80.0.361.50)) - id3((version: 79.0.309.68)) --> id6((cve_1)) - id3((version: 79.0.309.68)) --> id7((cve_2)) - id4((version: 80.0.361.48)) --> id8((cve_3)) - id5((version: 80.0.361.50)) --> id9((cve_4)) - id5((version: 80.0.361.50)) --> id10((target_sw: windows)) - id10((target_sw: windows)) --> id11((cve_5)) -``` - -So having version `80.0.361.50` of `Edge` installed on MacOS should only return `cve_4` but having -the same program in Windows should return both `cve_4` and `cve_5`. - -So basically our vulnerability detection problem can be broken down into two sub-problems: -1. Binding the software `vendor` and `name` attributes to known CPE `vendor` and `product` attributes (a.k.a the - binding problem). -2. Once we have the `vendor` and `product`, we will need to match that along with the version and other - characteristics (like language, platform, etc) to one or more target CPEs contained in the NVD - dataset (a.k.a the matching problem). - -## Binding the vendor portion - -For binding the `vendor`, we can use the `bundle_identifier` - using -[this](https://developer.apple.com/documentation/uikit/uidevice/1620059-identifierforvendor) as a -guideline - we can extract a 'pseudo vendor id' and filter out any top level domain names (since -the `bundle_identifier` is assumed to be in reverse-DNS format) and finally, transform the resulting -value if necessary. - -```mermaid -flowchart LR - bundle_identifier --> vendor_id - vendor_id --> remove_top_lv_domains - remove_top_lv_domains --> map_values -``` -Using the data [in -here](https://docs.google.com/spreadsheets/d/1D6Ub8_6YhLpVkmxLwTdGP8VWTH7rMS6-ZoBmJcrDDLE/edit?usp=sharing) -the following vendor translations where required (this list is not exhaustive): - -| bundle_identifier | extracted vendor | mapped vendor | -|---|---|---| -| com.postmanlabs.mac | postmanlabs | getpostman | -| com.tinyspeck.slackmacgap | tinyspeck | slack | -| com.getdropbox.dropbox | getdropbox | dropbox | -| ru.keepcoder.Telegram | keepcoder | telegram | -| org.virtualbox.app.VirtualBox | virtualbox | oracle | -| org.virtualbox.app.VirtualBox | Cisco-Systems | cisco | -| net.kovidgoyal.calibre | kovidgoyal | calibre-ebook | - -We will need to host and maintain some kind of metadata like this somewhere. - -## Binding the product portion - -For binding the `product`, we can use both the `bundle_executable` and the `bundle_name` -(sometimes we get matches with the `bundle_executable` sometimes we get matches with the -`bundle_name`) - the data processing pipeline would look something like this: - -```mermaid -flowchart LR - bundle_executable --> map_values - map_values --> to_lower - to_lower --> replace_spaces -``` -Again, like with the vendor portion, some translation was required. When testing this approach the -following translation were used: - -| vendor | bundle name/ executable | translation | -|---|---|---| -| oracle | VirtualBox| vm_virtualbox| -| agilebits | 1Password 7| 1password| -| zoom | zoom.us | zoom | -| microsoft | Microsoft AutoUpdate | autoupdate | -| microsoft | Microsoft Edge | edge | -| microsoft | Code | visual_studio_code | -| osquery | oqueryd | osquery | - -## Reference -To test this approach I used -[this](https://docs.google.com/spreadsheets/d/1D6Ub8_6YhLpVkmxLwTdGP8VWTH7rMS6-ZoBmJcrDDLE/edit?usp=sharing) -data as input (the apps sheet). Both the not_found and found sheets contain the apps that were not -found and found in the NVD dataset respectively. I checked that all entries in the `not_found` sheet -did have entries in the NVD dataset. - -For extracting the vendor and product portions from the NVD dataset I used the [following -script](https://gist.github.com/juan-fdz-hawa/52a9a54646a1cdc26359104d4f1a57e3). - -To determine matches/mismatches I used [following script](https://gist.github.com/juan-fdz-hawa/a3eb1cf33f149f7473a37469ecb9feda) \ No newline at end of file diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 937c73d279..55096ded00 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -1023,7 +1023,7 @@ allow { ## # Android ## -# Global admins can connect enteprise. +# Global admins can connect enterprise. allow { object.type == "android_enterprise" subject.global_role == admin diff --git a/server/mdm/android/pubsub.go b/server/mdm/android/pubsub.go index 998c7fd145..c2c188640d 100644 --- a/server/mdm/android/pubsub.go +++ b/server/mdm/android/pubsub.go @@ -1,10 +1,18 @@ package android +type NotificationType string + const ( - PubSubEnrollment = "ENROLLMENT" - PubSubStatusReport = "STATUS_REPORT" - PubSubCommand = "COMMAND" - PubSubUsageLogs = "USAGE_LOGS" + PubSubEnrollment NotificationType = "ENROLLMENT" + PubSubStatusReport NotificationType = "STATUS_REPORT" + PubSubCommand NotificationType = "COMMAND" + PubSubUsageLogs NotificationType = "USAGE_LOGS" +) + +type DeviceState string + +const ( + DeviceStateDeleted DeviceState = "DELETED" ) type PubSubMessage struct { diff --git a/server/mdm/android/service.go b/server/mdm/android/service.go index 6583c4762a..72a2657e14 100644 --- a/server/mdm/android/service.go +++ b/server/mdm/android/service.go @@ -9,6 +9,7 @@ type Service interface { EnterpriseSignupCallback(ctx context.Context, enterpriseID uint, enterpriseToken string) error GetEnterprise(ctx context.Context) (*Enterprise, error) DeleteEnterprise(ctx context.Context) error + EnterpriseSignupSSE(ctx context.Context) (chan string, error) // CreateEnrollmentToken creates an enrollment token for a new Android device. CreateEnrollmentToken(ctx context.Context, enrollSecret string) (*EnrollmentToken, error) diff --git a/server/mdm/android/service/enterprises_test.go b/server/mdm/android/service/enterprises_test.go index 9a99ed730c..24a8c830d9 100644 --- a/server/mdm/android/service/enterprises_test.go +++ b/server/mdm/android/service/enterprises_test.go @@ -97,6 +97,12 @@ func TestEnterprisesAuth(t *testing.T) { _, err = svc.EnterpriseSignup(ctx) checkAuthErr(t, tt.shouldFailWrite, err) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + _, err = svc.EnterpriseSignupSSE(ctx) + checkAuthErr(t, tt.shouldFailRead, err) + }) } diff --git a/server/mdm/android/service/handler.go b/server/mdm/android/service/handler.go index a5accf6340..9ede251100 100644 --- a/server/mdm/android/service/handler.go +++ b/server/mdm/android/service/handler.go @@ -25,6 +25,7 @@ func attachFleetAPIRoutes(r *mux.Router, fleetSvc fleet.Service, svc android.Ser ue.GET("/api/_version_/fleet/android_enterprise/signup_url", enterpriseSignupEndpoint, nil) ue.GET("/api/_version_/fleet/android_enterprise", getEnterpriseEndpoint, nil) ue.DELETE("/api/_version_/fleet/android_enterprise", deleteEnterpriseEndpoint, nil) + ue.GET("/api/_version_/fleet/android_enterprise/signup_sse", enterpriseSSE, nil) // ////////////////////////////////////////// // Unauthenticated endpoints diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go index 7e710d2031..0a5a497bec 100644 --- a/server/mdm/android/service/pubsub.go +++ b/server/mdm/android/service/pubsub.go @@ -50,7 +50,7 @@ func (svc *Service) ProcessPubSubPush(ctx context.Context, token string, message } } - switch notificationType { + switch android.NotificationType(notificationType) { case android.PubSubEnrollment: var device androidmanagement.Device err := json.Unmarshal(rawData, &device) @@ -59,6 +59,7 @@ func (svc *Service) ProcessPubSubPush(ctx context.Context, token string, message } err = svc.enrollHost(ctx, &device) if err != nil { + level.Debug(svc.logger).Log("msg", "Error enrolling Android host", "data", rawData) return ctxerr.Wrap(ctx, err, "enrolling Android host") } case android.PubSubStatusReport: @@ -67,6 +68,11 @@ func (svc *Service) ProcessPubSubPush(ctx context.Context, token string, message if err != nil { return ctxerr.Wrap(ctx, err, "unmarshal Android status report message") } + if device.AppliedState == string(android.DeviceStateDeleted) { + level.Debug(svc.logger).Log("msg", "Android device deleted from MDM", "device.name", device.Name, + "device.enterpriseSpecificId", device.HardwareInfo.EnterpriseSpecificId) + return nil + } host, err := svc.getExistingHost(ctx, &device) if err != nil { return ctxerr.Wrap(ctx, err, "getting existing Android host") @@ -77,11 +83,13 @@ func (svc *Service) ProcessPubSubPush(ctx context.Context, token string, message "device.enterpriseSpecificId", device.HardwareInfo.EnterpriseSpecificId) err = svc.enrollHost(ctx, &device) if err != nil { + level.Debug(svc.logger).Log("msg", "Error re-enrolling Android host", "data", rawData) return ctxerr.Wrap(ctx, err, "re-enrolling deleted Android host") } } err = svc.updateHost(ctx, &device, host) if err != nil { + level.Debug(svc.logger).Log("msg", "Error updating Android host", "data", rawData) return ctxerr.Wrap(ctx, err, "enrolling Android host") } } @@ -138,6 +146,10 @@ func (svc *Service) validateDevice(ctx context.Context, device *androidmanagemen } func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.Device, host *fleet.AndroidHost) error { + err := svc.validateDevice(ctx, device) + if err != nil { + return err + } if device.AppliedPolicyName != "" { policy, err := svc.getPolicyID(ctx, device) if err != nil { @@ -147,7 +159,7 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De if err != nil { return ctxerr.Wrap(ctx, err, "parsing Android policy sync time") } - host.Device.AndroidPolicyID = ptr.Uint(policy) + host.Device.AndroidPolicyID = policy host.Device.LastPolicySyncTime = ptr.Time(policySyncTime) } @@ -216,7 +228,7 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De if err != nil { return ctxerr.Wrap(ctx, err, "parsing Android policy sync time") } - host.Device.AndroidPolicyID = ptr.Uint(policy) + host.Device.AndroidPolicyID = policy host.Device.LastPolicySyncTime = ptr.Time(policySyncTime) } host.SetNodeKey(device.HardwareInfo.EnterpriseSpecificId) @@ -252,14 +264,20 @@ func (svc *Service) getDeviceID(ctx context.Context, device *androidmanagement.D return deviceID, nil } -func (svc *Service) getPolicyID(ctx context.Context, device *androidmanagement.Device) (uint, error) { +func (svc *Service) getPolicyID(ctx context.Context, device *androidmanagement.Device) (*uint, error) { nameParts := strings.Split(device.AppliedPolicyName, "/") if len(nameParts) != 4 { - return 0, ctxerr.Errorf(ctx, "invalid Android policy name: %s", device.AppliedPolicyName) + return nil, ctxerr.Errorf(ctx, "invalid Android policy name: %s", device.AppliedPolicyName) + } + if len(nameParts[3]) == 0 { + level.Error(svc.logger).Log("msg", "Empty Android policy ID", "device.name", device.Name, + "device.enterpriseSpecificID", device.HardwareInfo.EnterpriseSpecificId, "device.AppliedPolicyName", + device.AppliedPolicyName) + return nil, nil } result, err := strconv.ParseUint(nameParts[3], 10, 64) if err != nil { - return 0, ctxerr.Wrap(ctx, err, "parsing Android policy ID") + return nil, ctxerr.Wrapf(ctx, err, "parsing Android policy ID from %s", device.AppliedPolicyName) } - return uint(result), nil + return ptr.Uint(uint(result)), nil } diff --git a/server/mdm/android/service/service.go b/server/mdm/android/service/service.go index e786f67c83..da991dc318 100644 --- a/server/mdm/android/service/service.go +++ b/server/mdm/android/service/service.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" @@ -13,11 +14,16 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/android" "github.com/fleetdm/fleet/v4/server/mdm/android/service/proxy" kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" "google.golang.org/api/androidmanagement/v1" ) // We use numbers for policy names for easier mapping/indexing with Fleet DB. -const defaultAndroidPolicyID = 1 +const ( + defaultAndroidPolicyID = 1 + DefaultSignupSSEInterval = 3 * time.Second + SignupSSESuccess = "Android Enterprise successfully connected" +) type Service struct { logger kitlog.Logger @@ -25,6 +31,9 @@ type Service struct { ds android.Datastore fleetDS fleet.Datastore proxy android.Proxy + + // SignupSSEInterval can be overwritten in tests. + SignupSSEInterval time.Duration } func NewService( @@ -47,11 +56,12 @@ func NewServiceWithProxy( } return &Service{ - logger: logger, - authz: authorizer, - ds: fleetDS.GetAndroidDS(), - fleetDS: fleetDS, - proxy: proxy, + logger: logger, + authz: authorizer, + ds: fleetDS.GetAndroidDS(), + fleetDS: fleetDS, + proxy: proxy, + SignupSSEInterval: DefaultSignupSSEInterval, }, nil } @@ -155,10 +165,10 @@ func (svc *Service) EnterpriseSignupCallback(ctx context.Context, id uint, enter android.ProxyEnterprisesCreateRequest{ Enterprise: androidmanagement.Enterprise{ EnabledNotificationTypes: []string{ - android.PubSubEnrollment, - android.PubSubStatusReport, - android.PubSubCommand, - android.PubSubUsageLogs, + string(android.PubSubEnrollment), + string(android.PubSubStatusReport), + string(android.PubSubCommand), + string(android.PubSubUsageLogs), }, }, EnterpriseToken: enterpriseToken, @@ -358,3 +368,87 @@ func (svc *Service) checkIfAndroidNotConfigured(ctx context.Context) (*fleet.App } return appConfig, nil } + +type enterpriseSSEResponse struct { + android.DefaultResponse + done chan string +} + +func (r enterpriseSSEResponse) HijackRender(_ context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("Transfer-Encoding", "chunked") + if r.done == nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, "Error: No SSE data available") + return + } + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + + for { + select { + case data, ok := <-r.done: + if ok { + _, _ = fmt.Fprint(w, data) + w.(http.Flusher).Flush() + } + return + case <-time.After(5 * time.Second): + // We send a heartbeat to prevent the load balancer from closing the (otherwise idle) connection. + // The leading colon indicates this is a comment, and is ignored. + // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events + _, _ = fmt.Fprint(w, ":heartbeat\n") + w.(http.Flusher).Flush() + } + } +} + +func enterpriseSSE(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer { + done, err := svc.EnterpriseSignupSSE(ctx) + if err != nil { + return android.DefaultResponse{Err: err} + } + return enterpriseSSEResponse{done: done} +} + +func (svc *Service) EnterpriseSignupSSE(ctx context.Context) (chan string, error) { + if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionRead); err != nil { + return nil, err + } + + done := make(chan string) + go func() { + if svc.signupSSECheck(ctx, done) { + return + } + for { + select { + case <-ctx.Done(): + level.Debug(svc.logger).Log("msg", "Context cancelled during Android signup SSE") + return + case <-time.After(svc.SignupSSEInterval): + if svc.signupSSECheck(ctx, done) { + return + } + } + } + }() + + return done, nil +} + +func (svc *Service) signupSSECheck(ctx context.Context, done chan string) bool { + appConfig, err := svc.fleetDS.AppConfig(ctx) + if err != nil { + done <- fmt.Sprintf("Error getting app config: %v", err) + return true + } + if appConfig.MDM.AndroidEnabledAndConfigured { + done <- SignupSSESuccess + return true + } + return false +} diff --git a/server/mdm/android/tests/enterprise/enterprise_test.go b/server/mdm/android/tests/enterprise/enterprise_test.go index d8793239a6..0bdf6f17ba 100644 --- a/server/mdm/android/tests/enterprise/enterprise_test.go +++ b/server/mdm/android/tests/enterprise/enterprise_test.go @@ -1,12 +1,18 @@ package enterprise_test import ( + "context" + "io" "net/http" "testing" + "time" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/android" + "github.com/fleetdm/fleet/v4/server/mdm/android/service" "github.com/fleetdm/fleet/v4/server/mdm/android/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -22,6 +28,12 @@ type enterpriseTestSuite struct { func (s *enterpriseTestSuite) SetupSuite() { s.WithServer.SetupSuite(s.T(), "androidEnterpriseTestSuite") s.Token = "bozo" + s.Svc.(*service.Service).SignupSSEInterval = 10 * time.Millisecond +} + +func (s *enterpriseTestSuite) SetupTest() { + s.AppConfig.MDM.AndroidEnabledAndConfigured = false + s.CreateCommonDSMocks() } func (s *enterpriseTestSuite) TearDownSuite() { @@ -29,6 +41,8 @@ func (s *enterpriseTestSuite) TearDownSuite() { } func (s *enterpriseTestSuite) TestGetEnterprise() { + s.SetupTest() + // Enterprise doesn't exist. var resp android.GetEnterpriseResponse s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusNotFound, &resp) @@ -50,3 +64,44 @@ func (s *enterpriseTestSuite) TestGetEnterprise() { s.Do("DELETE", "/api/v1/fleet/android_enterprise", nil, http.StatusOK) s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusNotFound, &resp) } + +func (s *enterpriseTestSuite) TestEnterpriseSSE() { + s.SetupTest() + + // Test happy path + resp := s.Do("GET", "/api/v1/fleet/android_enterprise/signup_sse", nil, http.StatusOK) + sseDone := make(chan struct{}) + go func() { + data, err := io.ReadAll(resp.Body) + require.NoError(s.T(), err) + assert.Equal(s.T(), service.SignupSSESuccess, string(data)) + close(sseDone) + }() + + time.Sleep(50 * time.Millisecond) + s.AppConfigMu.Lock() + s.AppConfig.MDM.AndroidEnabledAndConfigured = true + s.AppConfigMu.Unlock() + + select { + case <-sseDone: + s.T().Log("SSE done") + case <-time.After(2 * time.Second): + s.T().Fatal("Timed out waiting for SSE") + } + + // Test with Android already enabled + resp = s.Do("GET", "/api/v1/fleet/android_enterprise/signup_sse", nil, http.StatusOK) + data, err := io.ReadAll(resp.Body) + require.NoError(s.T(), err) + assert.Equal(s.T(), service.SignupSSESuccess, string(data)) + + // Test with error + s.WithServer.FleetDS.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) { + return nil, assert.AnError + } + resp = s.Do("GET", "/api/v1/fleet/android_enterprise/signup_sse", nil, http.StatusOK) + data, err = io.ReadAll(resp.Body) + assert.NoError(s.T(), err) + assert.Contains(s.T(), string(data), assert.AnError.Error()) +} diff --git a/server/mdm/android/tests/testing_utils.go b/server/mdm/android/tests/testing_utils.go index acdf58378f..19c03e9ea0 100644 --- a/server/mdm/android/tests/testing_utils.go +++ b/server/mdm/android/tests/testing_utils.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "sync" "testing" "github.com/fleetdm/fleet/v4/server/config" @@ -36,18 +37,22 @@ const ( type WithServer struct { suite.Suite - DS *mysql.Datastore - FleetDS ds_mock.Store - Server *httptest.Server - Token string - AppConfig fleet.AppConfig + Svc android.Service + DS *mysql.Datastore + FleetDS ds_mock.Store + Server *httptest.Server + Token string + + AppConfig fleet.AppConfig + AppConfigMu sync.Mutex + Proxy proxy_mock.Proxy ProxyCallbackURL string } func (ts *WithServer) SetupSuite(t *testing.T, dbName string) { ts.DS = CreateNamedMySQLDS(t, dbName) - ts.createCommonDSMocks() + ts.CreateCommonDSMocks() ts.Proxy = proxy_mock.Proxy{} ts.createCommonProxyMocks(t) @@ -56,19 +61,26 @@ func (ts *WithServer) SetupSuite(t *testing.T, dbName string) { logger := kitlog.NewLogfmtLogger(os.Stdout) svc, err := service.NewServiceWithProxy(logger, &ts.FleetDS, &ts.Proxy) require.NoError(t, err) + ts.Svc = svc ts.Server = runServerForTests(t, logger, &fleetSvc, svc) } -func (ts *WithServer) createCommonDSMocks() { +func (ts *WithServer) CreateCommonDSMocks() { ts.FleetDS.GetAndroidDSFunc = func() android.Datastore { return ts.DS } ts.FleetDS.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) { - return &ts.AppConfig, nil + // Create a copy to prevent race conditions + ts.AppConfigMu.Lock() + appConfigCopy := ts.AppConfig + ts.AppConfigMu.Unlock() + return &appConfigCopy, nil } ts.FleetDS.SetAndroidEnabledAndConfiguredFunc = func(_ context.Context, configured bool) error { + ts.AppConfigMu.Lock() ts.AppConfig.MDM.AndroidEnabledAndConfigured = configured + ts.AppConfigMu.Unlock() return nil } } diff --git a/server/service/osquery.go b/server/service/osquery.go index ec9848d467..5df874a6e0 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1224,6 +1224,11 @@ func preProcessSoftwareResults( vsCodeExtensionsExtraQuery := hostDetailQueryPrefix + "software_vscode_extensions" preProcessSoftwareExtraResults(vsCodeExtensionsExtraQuery, host.ID, results, statuses, messages, osquery_utils.DetailQuery{}, logger) + pythonPackagesExtraQuery := hostDetailQueryPrefix + "software_python_packages" + preProcessSoftwareExtraResults(pythonPackagesExtraQuery, host.ID, results, statuses, messages, osquery_utils.DetailQuery{}, logger) + pythonPakcagesWithUsersExtraQuery := hostDetailQueryPrefix + "software_python_packages_with_users_dir" + preProcessSoftwareExtraResults(pythonPakcagesWithUsersExtraQuery, host.ID, results, statuses, messages, osquery_utils.DetailQuery{}, logger) + for name, query := range overrides { fullQueryName := hostDetailQueryPrefix + "software_" + name preProcessSoftwareExtraResults(fullQueryName, host.ID, results, statuses, messages, query, logger) diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index b8dfbbc636..15f389ddb2 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -1080,16 +1080,18 @@ func verifyDiscovery(t *testing.T, queries, discovery map[string]string) { assert.Equal(t, len(queries), len(discovery)) // discoveryUsed holds the queries where we know use the distributed discovery feature. discoveryUsed := map[string]struct{}{ - hostDetailQueryPrefix + "google_chrome_profiles": {}, - hostDetailQueryPrefix + "mdm": {}, - hostDetailQueryPrefix + "munki_info": {}, - hostDetailQueryPrefix + "windows_update_history": {}, - hostDetailQueryPrefix + "kubequery_info": {}, - hostDetailQueryPrefix + "orbit_info": {}, - hostDetailQueryPrefix + "software_vscode_extensions": {}, - hostDetailQueryPrefix + "software_macos_firefox": {}, - hostDetailQueryPrefix + "battery": {}, - hostDetailQueryPrefix + "software_macos_codesign": {}, + hostDetailQueryPrefix + "google_chrome_profiles": {}, + hostDetailQueryPrefix + "mdm": {}, + hostDetailQueryPrefix + "munki_info": {}, + hostDetailQueryPrefix + "windows_update_history": {}, + hostDetailQueryPrefix + "kubequery_info": {}, + hostDetailQueryPrefix + "orbit_info": {}, + hostDetailQueryPrefix + "software_vscode_extensions": {}, + hostDetailQueryPrefix + "software_python_packages": {}, + hostDetailQueryPrefix + "software_python_packages_with_users_dir": {}, + hostDetailQueryPrefix + "software_macos_firefox": {}, + hostDetailQueryPrefix + "battery": {}, + hostDetailQueryPrefix + "software_macos_codesign": {}, } for name := range queries { require.NotEmpty(t, discovery[name]) @@ -3709,6 +3711,25 @@ func TestPreProcessSoftwareResults(t *testing.T) { "installed_path": "/some/override/path", } + pythonPackageOne := map[string]string{ + "name": "cryptography", + "version": "41.0.7", + "extension_id": "", + "browser": "", + "source": "python_packages", + "vendor": "", + "installed_path": "/usr/lib/python3/dist-packages", + } + pythonPackageTwo := map[string]string{ + "name": "pip", + "version": "25.0.1", + "extension_id": "", + "browser": "", + "source": "python_packages", + "vendor": "", + "installed_path": "/Users/fleetdm/.pyenv/versions/3.13.1/lib/python3.13/site-packages", + } + for _, tc := range []struct { name string host *fleet.Host @@ -3719,6 +3740,50 @@ func TestPreProcessSoftwareResults(t *testing.T) { resultsOut fleet.OsqueryDistributedQueryResults }{ + { + name: "python packages using original query in extras adds results", + + statusesIn: map[string]fleet.OsqueryStatus{ + hostDetailQueryPrefix + "software_macos": fleet.StatusOK, + hostDetailQueryPrefix + "software_python_packages": fleet.StatusOK, + }, + resultsIn: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_macos": []map[string]string{ + foobarApp, + }, + hostDetailQueryPrefix + "software_python_packages": []map[string]string{ + pythonPackageOne, + }, + }, + resultsOut: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_macos": []map[string]string{ + foobarApp, + pythonPackageOne, + }, + }, + }, + { + name: "python packages using user query in extras adds results", + + statusesIn: map[string]fleet.OsqueryStatus{ + hostDetailQueryPrefix + "software_macos": fleet.StatusOK, + hostDetailQueryPrefix + "software_python_packages_with_users_dir": fleet.StatusOK, + }, + resultsIn: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_macos": []map[string]string{ + foobarApp, + }, + hostDetailQueryPrefix + "software_python_packages_with_users_dir": []map[string]string{ + pythonPackageTwo, + }, + }, + resultsOut: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_macos": []map[string]string{ + foobarApp, + pythonPackageTwo, + }, + }, + }, { name: "software query works and there are vs code extensions in extra", diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 51499656bc..5731a0174b 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -854,18 +854,6 @@ SELECT path AS installed_path FROM apps UNION -SELECT - name AS name, - version AS version, - '' AS bundle_identifier, - '' AS extension_id, - '' AS browser, - 'python_packages' AS source, - '' AS vendor, - 0 AS last_opened_at, - path AS installed_path -FROM python_packages -UNION SELECT name AS name, version AS version, @@ -1038,19 +1026,7 @@ SELECT '' AS vendor, '' AS arch, path AS installed_path -FROM cached_users CROSS JOIN firefox_addons USING (uid) -UNION -SELECT - name AS name, - version AS version, - '' AS extension_id, - '' AS browser, - 'python_packages' AS source, - '' AS release, - '' AS vendor, - '' AS arch, - path AS installed_path -FROM python_packages; +FROM cached_users CROSS JOIN firefox_addons USING (uid); `), Platforms: fleet.HostLinuxOSs, DirectIngestFunc: directIngestSoftware, @@ -1068,16 +1044,6 @@ SELECT install_location AS installed_path FROM programs UNION -SELECT - name AS name, - version AS version, - '' AS extension_id, - '' AS browser, - 'python_packages' AS source, - '' AS vendor, - path AS installed_path -FROM python_packages -UNION SELECT name AS name, version AS version, @@ -1122,6 +1088,44 @@ FROM chocolatey_packages DirectIngestFunc: directIngestSoftware, } +// In osquery versions < 5.16.0 use the original python_packages query, as the cross join on +// users is not supported +var softwarePythonPackages = DetailQuery{ + Description: "Prior to osquery version 5.16.0, the python_packages table did not search user directories.", + Query: ` + SELECT + name AS name, + version AS version, + '' AS extension_id, + '' AS browser, + 'python_packages' AS source, + '' AS vendor, + path AS installed_path + FROM python_packages + `, + Platforms: append(fleet.HostLinuxOSs, "darwin", "windows"), + Discovery: `SELECT 1 FROM osquery_info WHERE version_compare(version, '5.16.0') < 0`, +} + +// In osquery versions >= 5.16.0 the python_packages table was modified to allow for a +// cross join on users so that user directories could be searched for python packages +var softwarePythonPackagesWithUsersDir = DetailQuery{ + Description: "As of osquery version 5.16.0, the python_packages table searches user directories with support from a cross join on users. See https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table.", + Query: withCachedUsers(`WITH cached_users AS (%s) + SELECT + name AS name, + version AS version, + '' AS extension_id, + '' AS browser, + 'python_packages' AS source, + '' AS vendor, + path AS installed_path + FROM cached_users CROSS JOIN python_packages USING (uid) + `), + Platforms: append(fleet.HostLinuxOSs, "darwin", "windows"), + Discovery: `SELECT 1 FROM osquery_info WHERE version_compare(version, '5.16.0') >= 0`, +} + var softwareChrome = DetailQuery{ Query: `SELECT name AS name, @@ -2258,6 +2262,8 @@ func GetDetailQueries( generatedMap["software_linux"] = softwareLinux generatedMap["software_windows"] = softwareWindows generatedMap["software_chrome"] = softwareChrome + generatedMap["software_python_packages"] = softwarePythonPackages + generatedMap["software_python_packages_with_users_dir"] = softwarePythonPackagesWithUsersDir generatedMap["software_vscode_extensions"] = softwareVSCodeExtensions for key, query := range SoftwareOverrideQueries { diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 612be8d081..86c7895c6a 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -308,7 +308,7 @@ func TestGetDetailQueries(t *testing.T) { queriesWithUsersAndSoftware := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true, EnableSoftwareInventory: true}) qs = baseQueries qs = append(qs, "users", "users_chrome", "software_macos", "software_linux", "software_windows", "software_vscode_extensions", - "software_chrome", "scheduled_query_stats", "software_macos_firefox", "software_macos_codesign") + "software_chrome", "software_python_packages", "software_python_packages_with_users_dir", "scheduled_query_stats", "software_macos_firefox", "software_macos_codesign") require.Len(t, queriesWithUsersAndSoftware, len(qs)) sortedKeysCompare(t, queriesWithUsersAndSoftware, qs) diff --git a/server/vulnerabilities/nvd/cpe_matching_rules.go b/server/vulnerabilities/nvd/cpe_matching_rules.go index b1eaa68813..e1776c2991 100644 --- a/server/vulnerabilities/nvd/cpe_matching_rules.go +++ b/server/vulnerabilities/nvd/cpe_matching_rules.go @@ -209,6 +209,25 @@ func GetKnownNVDBugRules() (CPEMatchingRules, error) { return cpeMeta.Product == "visual_studio_code" && cpeMeta.TargetSW == wfn.Any }, }, + // CVE-2023-48795 in NVD incorrectly mentions PowerShell as vulnerable when the issue is actually with OpenSSH, + // which is packaged separately. It also includes a bogus resolved-in version number. See #26073. + CPEMatchingRule{ + CVEs: map[string]struct{}{ + "CVE-2023-48795": {}, + }, + IgnoreIf: func(cpeMeta *wfn.Attributes) bool { + return cpeMeta.Vendor == "microsoft" && cpeMeta.Product == "powershell" + }, + }, + // CVE-2025-21171 only affects RC versions of PowerShell, see https://github.com/PowerShell/Announcements/issues/72 + CPEMatchingRule{ + CVEs: map[string]struct{}{ + "CVE-2025-21171": {}, + }, + IgnoreIf: func(cpeMeta *wfn.Attributes) bool { + return cpeMeta.Vendor == "microsoft" && cpeMeta.Product == "powershell" && cpeMeta.Update == "" + }, + }, // Old macos CPEs without version constraints that should be ignored CPEMatchingRule{ CVEs: map[string]struct{}{ diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go index 51da562008..811bcc6fb6 100644 --- a/server/vulnerabilities/nvd/cve_test.go +++ b/server/vulnerabilities/nvd/cve_test.go @@ -367,6 +367,30 @@ func TestTranslateCPEToCVE(t *testing.T) { excludedCVEs: []string{"CVE-2024-10327"}, continuesToUpdate: true, }, + // CVE-2023-48795 false positive and true positive checks (see #26073) + "cpe:2.3:a:microsoft:powershell:7.4.3:*:*:*:*:*:*:*": { + excludedCVEs: []string{"CVE-2023-48795", "CVE-2025-21171"}, + continuesToUpdate: true, + }, + "cpe:2.3:a:openbsd:openssh:9.5:p1:*:*:*:*:*:*": { + includedCVEs: []cve{{ID: "CVE-2023-48795", resolvedInVersion: "9.6"}}, + continuesToUpdate: true, + }, + "cpe:2.3:a:openbsd:openssh:9.6:*:*:*:*:*:*": { + excludedCVEs: []string{"CVE-2023-48795"}, + continuesToUpdate: true, + }, + // end of CVE-2023-48795 checks + // CVE-2025-21171 handling + "cpe:2.3:a:microsoft:powershell:7.5.0:*:*:*:*:macos:*:*": { + excludedCVEs: []string{"CVE-2025-21171"}, + continuesToUpdate: true, + }, + "cpe:2.3:a:microsoft:powershell:7.5.0:rc.1:*:*:*:macos:*:*": { + includedCVEs: []cve{{ID: "CVE-2025-21171"}}, + continuesToUpdate: true, + }, + // end of CVE-2025-21171 checks "cpe:2.3:a:jetbrains:goland:2022.3.99.123.456:*:*:*:*:macos:*:*": { includedCVEs: []cve{{ID: "CVE-2024-37051", resolvedInVersion: "2023.1.6"}}, continuesToUpdate: true,