diff --git a/changes/20413-apple-hardware-marketing-names b/changes/20413-apple-hardware-marketing-names new file mode 100644 index 0000000000..0b34ff55dd --- /dev/null +++ b/changes/20413-apple-hardware-marketing-names @@ -0,0 +1 @@ +- Added marketing name display for Apple devices (macOS, iOS, iPadOS) on the Hosts and Host details pages. The "Hardware model" field now shows human-readable names (e.g. "MacBook Pro (16-inch, 2021)") instead of raw identifiers (e.g. "MacBookPro18,1"). diff --git a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json index 4c58ed7960..2b23fcbfa6 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json @@ -23,6 +23,7 @@ "gigs_all_disk_space": null, "gigs_disk_space_available": 0, "gigs_total_disk_space": 0, + "hardware_marketing_name": "", "hardware_model": "", "hardware_serial": "", "hardware_vendor": "", diff --git a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml index 94124d5684..03d0be72de 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml @@ -23,6 +23,7 @@ spec: gigs_all_disk_space: null gigs_disk_space_available: 0 gigs_total_disk_space: 0 + hardware_marketing_name: "" hardware_model: "" hardware_serial: "" hardware_vendor: "" diff --git a/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json b/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json index 1761171cf9..258e754407 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json @@ -30,6 +30,7 @@ "gigs_all_disk_space": null, "gigs_disk_space_available": 0, "gigs_total_disk_space": 0, + "hardware_marketing_name": "", "hardware_model": "", "hardware_serial": "", "hardware_vendor": "", @@ -103,6 +104,7 @@ "gigs_all_disk_space": null, "gigs_disk_space_available": 0, "gigs_total_disk_space": 0, + "hardware_marketing_name": "", "hardware_model": "", "hardware_serial": "", "hardware_vendor": "", diff --git a/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json b/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json index b25dbea74b..37bafc5a5e 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json @@ -31,6 +31,7 @@ "gigs_all_disk_space": null, "gigs_disk_space_available": 0, "gigs_total_disk_space": 0, + "hardware_marketing_name": "", "hardware_model": "", "hardware_serial": "", "hardware_vendor": "", @@ -104,6 +105,7 @@ "gigs_all_disk_space": null, "gigs_disk_space_available": 0, "gigs_total_disk_space": 0, + "hardware_marketing_name": "", "hardware_model": "", "hardware_serial": "", "hardware_vendor": "", diff --git a/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml index a5b2edf22a..15d2597302 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml @@ -26,6 +26,7 @@ spec: gigs_all_disk_space: null gigs_disk_space_available: 0 gigs_total_disk_space: 0 + hardware_marketing_name: "" hardware_model: "" hardware_serial: "" hardware_vendor: "" @@ -95,6 +96,7 @@ spec: gigs_all_disk_space: null gigs_disk_space_available: 0 gigs_total_disk_space: 0 + hardware_marketing_name: "" hardware_model: "" hardware_serial: "" hardware_vendor: "" diff --git a/frontend/__mocks__/hostMock.ts b/frontend/__mocks__/hostMock.ts index 1b9c555658..12aabb7dd6 100644 --- a/frontend/__mocks__/hostMock.ts +++ b/frontend/__mocks__/hostMock.ts @@ -64,6 +64,7 @@ const DEFAULT_HOST_MOCK: IHost = { cpu_logical_cores: 8, hardware_vendor: "", hardware_model: "", + hardware_marketing_name: "", hardware_version: "", hardware_serial: "", computer_name: "9b20fc72a247", diff --git a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx index dcaacdddd3..5c6f18129b 100644 --- a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx @@ -19,6 +19,10 @@ interface ITooltipTruncatedTextCellProps { prefix?: React.ReactNode; /** Content does not get truncated */ suffix?: React.ReactNode; + /** When `true`, show the tooltip even when the text is not truncated. Use + * when the tooltip carries supplemental info (e.g. a raw identifier behind a + * friendlier display value) rather than just the truncated text. */ + alwaysShowTooltip?: boolean; } const baseClass = "tooltip-truncated-cell"; @@ -30,6 +34,7 @@ const TooltipTruncatedTextCell = ({ className, prefix, suffix, + alwaysShowTooltip = false, }: ITooltipTruncatedTextCellProps): JSX.Element => { const classNames = classnames(baseClass, className, { "tooltip-break-on-word": tooltipBreakOnWord, @@ -62,7 +67,9 @@ const TooltipTruncatedTextCell = ({ className="data-table__tooltip-truncated-text-container" data-tip data-for={tooltipId} - data-tip-disable={isDefaultValue || tooltipDisabled} + data-tip-disable={ + isDefaultValue || (tooltipDisabled && !alwaysShowTooltip) + } > { - const classNames = classnames(baseClass, className); - - // Tooltip visibility logic: Enable only when text is truncated + // Tooltip visibility logic: Enable when text is truncated, or always when + // `alwaysShowTooltip` is set (supplemental info tooltip). const ref = useRef(null); const isTruncated = useCheckTruncatedElement(ref); + const showTooltip = !disableTooltip && (isTruncated || alwaysShowTooltip); + // Underline the value to signal a supplemental tooltip is available. + // Truncation-only tooltips are not underlined. + const underline = showTooltip && alwaysShowTooltip; + + const classNames = classnames(baseClass, className, { + [`${baseClass}--underline`]: underline, + }); + // TODO: RachelPerkins unreleased bug refactor to include mobile tapping/click return ( & { // This is used to prevent these columns from being hidden. This will be @@ -189,9 +189,21 @@ const allHostTableHeaders = (teamId?: number): IHostTableColumnConfig[] => [ ), accessor: "hardware_model", id: "hardware_model", - Cell: (cellProps: IHostTableStringCellProps) => ( - - ), + Cell: (cellProps: IHostTableStringCellProps) => { + const { value, tooltip, alwaysShowTooltip } = getHardwareModelDisplay( + cellProps.row.original.platform, + cellProps.cell.value, + cellProps.row.original.hardware_marketing_name + ); + return ( + + ); + }, }, // User email { diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 076eef4bac..c5fd9e57f9 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -1638,10 +1638,14 @@ const ManageHostsPage = ({ .filter((element) => element !== "" && element !== "selection") // "agent" is a display-only column that coalesces orbit and osquery // versions; it has no corresponding CSV field on the backend, so we - // substitute the real fields it's derived from. + // substitute the real fields it's derived from. Likewise, the + // "hardware_model" column also surfaces the Apple marketing name in + // the UI, so we export both fields separately. .reduce((acc: string[], element) => { if (element === "agent") { acc.push("orbit_version", "osquery_version"); + } else if (element === "hardware_model") { + acc.push("hardware_model", "hardware_marketing_name"); } else { acc.push(element); } diff --git a/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx b/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx index c7eb996a07..ca1fc88f57 100644 --- a/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx +++ b/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx @@ -52,7 +52,8 @@ describe("Vitals Card component", () => { it("renders Enrollment ID and Hardware model for personally enrolled iOS hosts", () => { const mockHost = createMockHost({ platform: "ios", - hardware_model: "iPhone 12", + hardware_model: "iPhone12,1", + hardware_marketing_name: "iPhone 11", hardware_serial: "", uuid: "enrollment-id-12345", mdm: createMockHostMdmData({ @@ -65,7 +66,7 @@ describe("Vitals Card component", () => { expect(screen.getByText("Enrollment ID")).toBeInTheDocument(); expect(screen.getAllByText("enrollment-id-12345")[0]).toBeInTheDocument(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("iPhone 12")).toBeInTheDocument(); + expect(screen.getByText("iPhone 11")).toBeInTheDocument(); expect(screen.queryByText("Serial number")).not.toBeInTheDocument(); expect(screen.queryByText("Private IP address")).not.toBeInTheDocument(); expect(screen.queryByText("Public IP address")).not.toBeInTheDocument(); @@ -74,7 +75,8 @@ describe("Vitals Card component", () => { it("renders Enrollment ID and Hardware model for personally enrolled iPad hosts", () => { const mockHost = createMockHost({ platform: "ipados", - hardware_model: "IPad Pro", + hardware_model: "iPad14,5", + hardware_marketing_name: "iPad Pro 12.9-inch (6th generation) Wi-Fi", hardware_serial: "", uuid: "enrollment-id-12345", mdm: createMockHostMdmData({ @@ -87,7 +89,9 @@ describe("Vitals Card component", () => { expect(screen.getByText("Enrollment ID")).toBeInTheDocument(); expect(screen.getAllByText("enrollment-id-12345")[0]).toBeInTheDocument(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("IPad Pro")).toBeInTheDocument(); + expect( + screen.getByText("iPad Pro 12.9-inch (6th generation) Wi-Fi") + ).toBeInTheDocument(); expect(screen.queryByText("Serial number")).not.toBeInTheDocument(); expect(screen.queryByText("Private IP address")).not.toBeInTheDocument(); expect(screen.queryByText("Public IP address")).not.toBeInTheDocument(); @@ -96,7 +100,8 @@ describe("Vitals Card component", () => { it("renders Serial number and Hardware model for non-personally enrolled iOS hosts", () => { const mockHost = createMockHost({ platform: "ios", - hardware_model: "iPhone 12", + hardware_model: "iPhone12,1", + hardware_marketing_name: "iPhone 11", hardware_serial: "123-456-789", uuid: "enrollment-id-12345", mdm: createMockHostMdmData({ @@ -107,7 +112,7 @@ describe("Vitals Card component", () => { render(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("iPhone 12")).toBeInTheDocument(); + expect(screen.getByText("iPhone 11")).toBeInTheDocument(); expect(screen.getByText("Serial number")).toBeInTheDocument(); expect(screen.getAllByText("123-456-789")[0]).toBeInTheDocument(); expect(screen.queryByText("Enrollment ID")).not.toBeInTheDocument(); @@ -118,7 +123,8 @@ describe("Vitals Card component", () => { it("renders Enrollment ID and Hardware model for non-personally enrolled iPad hosts", () => { const mockHost = createMockHost({ platform: "ipados", - hardware_model: "IPad Pro", + hardware_model: "iPad14,5", + hardware_marketing_name: "iPad Pro 12.9-inch (6th generation) Wi-Fi", hardware_serial: "123-456-789", uuid: "enrollment-id-12345", mdm: createMockHostMdmData({ @@ -129,7 +135,9 @@ describe("Vitals Card component", () => { render(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("IPad Pro")).toBeInTheDocument(); + expect( + screen.getByText("iPad Pro 12.9-inch (6th generation) Wi-Fi") + ).toBeInTheDocument(); expect(screen.getByText("Serial number")).toBeInTheDocument(); expect(screen.getAllByText("123-456-789")[0]).toBeInTheDocument(); expect(screen.queryByText("Enrollment ID")).not.toBeInTheDocument(); @@ -140,7 +148,8 @@ describe("Vitals Card component", () => { it("render Hardware model, IP addresses, and EnrollmentID for all non android and ios/ipad hosts that have enrolled their personal mdm devices", () => { const mockHost = createMockHost({ platform: "darwin", - hardware_model: "MacBook Pro", + hardware_model: "MacBookPro18,1", + hardware_marketing_name: "MacBook Pro (16-inch, 2021)", hardware_serial: "", primary_ip: "192.168.1.1", public_ip: "203.0.113.1", @@ -155,7 +164,7 @@ describe("Vitals Card component", () => { expect(screen.getByText("Enrollment ID")).toBeInTheDocument(); expect(screen.getAllByText("enrollment-id-12345")[0]).toBeInTheDocument(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("MacBook Pro")).toBeInTheDocument(); + expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument(); expect(screen.getByText("Private IP address")).toBeInTheDocument(); expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument(); expect(screen.getByText("Public IP address")).toBeInTheDocument(); @@ -166,7 +175,8 @@ describe("Vitals Card component", () => { it("render Hardware model, IP addresses, and Serial number for all non android and ios/ipad hosts that have enrolled not enrolled in MDM", () => { const mockHost = createMockHost({ platform: "darwin", - hardware_model: "MacBook Pro", + hardware_model: "MacBookPro18,1", + hardware_marketing_name: "MacBook Pro (16-inch, 2021)", hardware_serial: "test-serial-number", primary_ip: "192.168.1.1", public_ip: "203.0.113.1", @@ -177,7 +187,7 @@ describe("Vitals Card component", () => { render(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("MacBook Pro")).toBeInTheDocument(); + expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument(); expect(screen.getByText("Private IP address")).toBeInTheDocument(); expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument(); expect(screen.getByText("Public IP address")).toBeInTheDocument(); @@ -190,7 +200,8 @@ describe("Vitals Card component", () => { it("render Hardware model, IP addresses, and Serial number for all non android and ios/ipad hosts that have manually enrolled in MDM", () => { const mockHost = createMockHost({ platform: "darwin", - hardware_model: "MacBook Pro", + hardware_model: "MacBookPro18,1", + hardware_marketing_name: "MacBook Pro (16-inch, 2021)", hardware_serial: "test-serial-number", primary_ip: "192.168.1.1", public_ip: "203.0.113.1", @@ -203,7 +214,7 @@ describe("Vitals Card component", () => { render(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("MacBook Pro")).toBeInTheDocument(); + expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument(); expect(screen.getByText("Private IP address")).toBeInTheDocument(); expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument(); expect(screen.getByText("Public IP address")).toBeInTheDocument(); @@ -216,7 +227,8 @@ describe("Vitals Card component", () => { it("render Hardware model, IP addresses, and Serial number for all non android and ios/ipad hosts that have automatically enrolled in MDM", () => { const mockHost = createMockHost({ platform: "darwin", - hardware_model: "MacBook Pro", + hardware_model: "MacBookPro18,1", + hardware_marketing_name: "MacBook Pro (16-inch, 2021)", hardware_serial: "test-serial-number", primary_ip: "192.168.1.1", public_ip: "203.0.113.1", @@ -229,7 +241,7 @@ describe("Vitals Card component", () => { render(); expect(screen.getByText("Hardware model")).toBeInTheDocument(); - expect(screen.getByText("MacBook Pro")).toBeInTheDocument(); + expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument(); expect(screen.getByText("Private IP address")).toBeInTheDocument(); expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument(); expect(screen.getByText("Public IP address")).toBeInTheDocument(); diff --git a/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx b/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx index 4376a44009..24ef40b03d 100644 --- a/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx +++ b/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx @@ -23,6 +23,7 @@ import { removeOSPrefix, compareVersions, } from "utilities/helpers"; +import { getHardwareModelDisplay } from "pages/hosts/helpers"; import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; import TooltipWrapper from "components/TooltipWrapper"; @@ -347,13 +348,24 @@ const Vitals = ({ } // Hardware model + const hardwareModelDisplay = getHardwareModelDisplay( + vitalsData.platform, + vitalsData.hardware_model, + vitalsData.hardware_marketing_name + ); vitals.push({ sortKey: "Hardware model", element: ( } + value={ + + } /> ), }); diff --git a/frontend/pages/hosts/helpers.ts b/frontend/pages/hosts/helpers.ts deleted file mode 100644 index b06aa8853b..0000000000 --- a/frontend/pages/hosts/helpers.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; - -export const getHostStatusTooltipText = (status: string): string => { - if (status === "online") { - return "Online hosts will respond to a live report."; - } - if (status === DEFAULT_EMPTY_CELL_VALUE) { - return "Device is pending enrollment in Apple Business and status is not yet available."; - } - return "Offline hosts won't respond to a live report because they may be shut down, asleep, or not connected to the internet."; -}; - -export const getHostStatus = ( - status: string, - mdmEnrollmentStatus?: string -): string => { - if (mdmEnrollmentStatus === "Pending") { - return DEFAULT_EMPTY_CELL_VALUE; - } - - return status || DEFAULT_EMPTY_CELL_VALUE; -}; diff --git a/frontend/pages/hosts/helpers.tsx b/frontend/pages/hosts/helpers.tsx new file mode 100644 index 0000000000..1737252d32 --- /dev/null +++ b/frontend/pages/hosts/helpers.tsx @@ -0,0 +1,65 @@ +import React from "react"; + +import { isAppleDevice } from "interfaces/platform"; +import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; + +export const getHostStatusTooltipText = (status: string): string => { + if (status === "online") { + return "Online hosts will respond to a live report."; + } + if (status === DEFAULT_EMPTY_CELL_VALUE) { + return "Device is pending enrollment in Apple Business and status is not yet available."; + } + return "Offline hosts won't respond to a live report because they may be shut down, asleep, or not connected to the internet."; +}; + +export const getHostStatus = ( + status: string, + mdmEnrollmentStatus?: string +): string => { + if (mdmEnrollmentStatus === "Pending") { + return DEFAULT_EMPTY_CELL_VALUE; + } + + return status || DEFAULT_EMPTY_CELL_VALUE; +}; + +// getHardwareModelDisplay computes how a host's hardware model is presented. +// Apple devices with a known marketing name (e.g. "MacBook Pro (16-inch, +// 2021)") show it in place of the raw model and reveal the raw model in a +// tooltip; otherwise the raw model is shown with no supplemental tooltip. +// An empty field may be "" (raw API data) or DEFAULT_EMPTY_CELL_VALUE (data +// that went through normalizeEmptyValues) — both count as "no value". +export const getHardwareModelDisplay = ( + platform: string, + hardwareModel: string, + hardwareMarketingName: string +): { value: string; tooltip?: JSX.Element; alwaysShowTooltip: boolean } => { + const isEmpty = (val: string) => !val || val === DEFAULT_EMPTY_CELL_VALUE; + + const marketingName = + isAppleDevice(platform) && !isEmpty(hardwareMarketingName) + ? hardwareMarketingName + : ""; + // Only reveal the raw model on hover when we're actually showing a distinct + // marketing name in its place. When there's no mapping the marketing name is + // empty (or echoes the raw model), so we show the raw model plainly with no + // tooltip. + const showModelTooltip = + !!marketingName && + !isEmpty(hardwareModel) && + marketingName !== hardwareModel; + + return { + value: marketingName || hardwareModel, + tooltip: showModelTooltip ? ( + // Left-align to override the tooltip's default centered text. +
+ Model: {hardwareModel} +
+ Marketing name: {marketingName} +
+ ) : undefined, + alwaysShowTooltip: showModelTooltip, + }; +}; diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index 2e10039c05..4daa2127f1 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -449,6 +449,7 @@ export const HOST_VITALS_DATA = [ "uptime", "last_enrolled_at", "hardware_model", + "hardware_marketing_name", "hardware_serial", "primary_ip", "public_ip", diff --git a/orbit/changes/20413-apple-hardware-marketing-names b/orbit/changes/20413-apple-hardware-marketing-names new file mode 100644 index 0000000000..be64961f02 --- /dev/null +++ b/orbit/changes/20413-apple-hardware-marketing-names @@ -0,0 +1 @@ +- Added new `apple_hardware_info` osquery extension table (macOS only) with a `marketing_name` column that returns the human-readable marketing name for the current Apple device. diff --git a/orbit/pkg/table/apple_hardware_info/apple_hardware_info_darwin.go b/orbit/pkg/table/apple_hardware_info/apple_hardware_info_darwin.go new file mode 100644 index 0000000000..d32fab8516 --- /dev/null +++ b/orbit/pkg/table/apple_hardware_info/apple_hardware_info_darwin.go @@ -0,0 +1,46 @@ +//go:build darwin + +package apple_hardware_info + +import ( + "context" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + osqclient "github.com/osquery/osquery-go" + "github.com/osquery/osquery-go/plugin/table" +) + +// Columns defines the schema for the apple_hardware_info table. +func Columns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.TextColumn("marketing_name"), + } +} + +// Generate queries system_info for the hardware model and maps it to its marketing name. +func Generate(ctx context.Context, _ table.QueryContext, socket string) ([]map[string]string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + c, err := osqclient.NewClient(socket, 2*time.Second) + if err != nil { + return nil, err + } + defer c.Close() + + row, err := c.QueryRowContext(ctx, "SELECT hardware_model FROM system_info") + if err != nil { + return nil, err + } + + model := row["hardware_model"] + + // Return an empty marketing_name when there's no mapping entry so a missing + // mapping can be told apart from the raw model identifier. + name := fleet.AppleHardwareModelsToMarketingNames[model] + + return []map[string]string{{ + "marketing_name": name, + }}, nil +} diff --git a/orbit/pkg/table/extension_darwin.go b/orbit/pkg/table/extension_darwin.go index 38515391fe..3576e3c742 100644 --- a/orbit/pkg/table/extension_darwin.go +++ b/orbit/pkg/table/extension_darwin.go @@ -7,6 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/table/adobe_plugins" "github.com/fleetdm/fleet/v4/orbit/pkg/table/app_sso_platform" + "github.com/fleetdm/fleet/v4/orbit/pkg/table/apple_hardware_info" "github.com/fleetdm/fleet/v4/orbit/pkg/table/authdb" "github.com/fleetdm/fleet/v4/orbit/pkg/table/codesign" "github.com/fleetdm/fleet/v4/orbit/pkg/table/csrutil_info" @@ -54,6 +55,13 @@ func PlatformTables(opts PluginOpts) ([]osquery.OsqueryPlugin, error) { plugins := []osquery.OsqueryPlugin{ // Fleet tables adobe_plugins.TablePlugin(log.Logger), + table.NewPlugin( + "apple_hardware_info", + apple_hardware_info.Columns(), + func(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + return apple_hardware_info.Generate(ctx, queryContext, opts.Socket) + }, + ), table.NewPlugin("icloud_private_relay", privaterelay.Columns(), privaterelay.Generate), table.NewPlugin("user_login_settings", user_login_settings.Columns(), user_login_settings.Generate), table.NewPlugin("pwd_policy", pwd_policy.Columns(), pwd_policy.Generate), diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index 91628176b4..7a3fbfeee5 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -1157,6 +1157,26 @@ "osqueryRepoUrl": "https://github.com/osquery/osquery/blob/master/specs/windows/appcompat_shims.table", "fleetRepoUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fappcompat_shims.yml&value=name%3A%20appcompat_shims%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." }, + { + "name": "apple_hardware_info", + "platforms": [ + "darwin" + ], + "description": "Maps the Apple hardware model identifier to its marketing name.", + "examples": "Get the marketing name for the current Mac.\n\n```\nSELECT marketing_name FROM apple_hardware_info;\n```\n\nJoin with `system_info` to get both the identifier and the marketing name.\n\n```\nSELECT si.hardware_model, ahi.marketing_name FROM system_info si, apple_hardware_info ahi;\n```", + "columns": [ + { + "name": "marketing_name", + "type": "text", + "required": false, + "description": "The Apple marketing name for the hardware, e.g. MacBook Pro (16-inch, Nov 2023)." + } + ], + "notes": "This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)).", + "evented": false, + "url": "https://fleetdm.com/tables/apple_hardware_info", + "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/apple_hardware_info.yml" + }, { "name": "apps", "description": "macOS applications installed in known search paths (e.g., /Applications).", diff --git a/schema/tables/apple_hardware_info.yml b/schema/tables/apple_hardware_info.yml new file mode 100644 index 0000000000..1ac71069cf --- /dev/null +++ b/schema/tables/apple_hardware_info.yml @@ -0,0 +1,24 @@ +name: apple_hardware_info +platforms: + - darwin +description: Maps the Apple hardware model identifier to its marketing name. +examples: |- + Get the marketing name for the current Mac. + + ``` + SELECT marketing_name FROM apple_hardware_info; + ``` + + Join with `system_info` to get both the identifier and the marketing name. + + ``` + SELECT si.hardware_model, ahi.marketing_name FROM system_info si, apple_hardware_info ahi; + ``` +columns: + - name: marketing_name + type: text + required: false + description: "The Apple marketing name for the hardware, e.g. MacBook Pro (16-inch, Nov 2023)." +notes: |- + This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). +evented: false diff --git a/server/fleet/apple_hardware_models.go b/server/fleet/apple_hardware_models.go new file mode 100644 index 0000000000..4a487642d4 --- /dev/null +++ b/server/fleet/apple_hardware_models.go @@ -0,0 +1,431 @@ +package fleet + +// AppleHardwareModelsToMarketingNames' names are primarily sourced from SOFA's +// device identifier data: +// https://github.com/macadmins/sofa/blob/5a4fe284215d666a08d6ea9f66a554c90226b57c/data/models/device_identifiers.json. +// +// Add new entries here as Apple releases new devices. +var AppleHardwareModelsToMarketingNames = map[string]string{ + "AppleTV1,1": "Apple TV (1st generation)", + "AppleTV11,1": "Apple TV 4K (2nd generation)", + "AppleTV14,1": "Apple TV 4K (3rd generation)", + "AppleTV2,1": "Apple TV (2nd generation)", + "AppleTV3,1": "Apple TV (3rd generation)", + "AppleTV3,2": "Apple TV (3rd generation, Rev A)", + "AppleTV5,3": "Apple TV HD (4th generation)", + "AppleTV6,2": "Apple TV 4K (1st generation)", + "Mac13,1": "Mac Studio (2022)", + "Mac13,2": "Mac Studio (2022)", + "Mac14,10": "MacBook Pro (16-inch, 2023)", + "Mac14,12": "Mac Mini M2 Pro (2023)", + "Mac14,13": "Mac Studio (2023)", + "Mac14,14": "Mac Studio (2023)", + "Mac14,15": "MacBook Air (15-inch, M2, 2023)", + "Mac14,2": "MacBook Air (M2, 2022)", + "Mac14,3": "Mac Mini M2 (2023)", + "Mac14,5": "MacBook Pro (14-inch, 2023)", + "Mac14,6": "MacBook Pro (16-inch, 2023)", + "Mac14,7": "MacBook Pro (13-inch, M2, 2022)", + "Mac14,8": "Mac Pro (2023)", + "Mac14,9": "MacBook Pro (14-inch, 2023)", + "Mac15,10": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,11": "MacBook Pro (16-inch, Nov 2023)", + "Mac15,12": "MacBook Air (13-inch, M3, 2024)", + "Mac15,13": "MacBook Air (15-inch, M3, 2024)", + "Mac15,14": "Mac Studio (2025)", + "Mac15,3": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,4": "iMac (24-inch, 2023, Two ports)", + "Mac15,5": "iMac (24-inch, 2023, Four ports)", + "Mac15,6": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,7": "MacBook Pro (16-inch, Nov 2023)", + "Mac15,8": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,9": "MacBook Pro (16-inch, Nov 2023)", + "Mac16,1": "MacBook Pro (14-inch, 2024)", + "Mac16,10": "Mac Mini M4 (2024)", + "Mac16,11": "Mac Mini M4 Pro (2024)", + "Mac16,12": "MacBook Air (13-inch, M4, 2025)", + "Mac16,13": "MacBook Air (15-inch, M4, 2025)", + "Mac16,15": "Mac Mini M4 Pro (2024)", + "Mac16,2": "iMac (24-inch, 2024, Two ports)", + "Mac16,3": "iMac (24-inch, 2024, Four ports)", + "Mac16,5": "MacBook Pro (16-inch, 2024)", + "Mac16,6": "MacBook Pro (14-inch, 2024)", + "Mac16,7": "MacBook Pro (16-inch, 2024)", + "Mac16,8": "MacBook Pro (14-inch, 2024)", + "Mac16,9": "Mac Studio (2025)", + "Mac17,2": "MacBook Pro 14-inch (M5)", + "Mac17,3": "MacBook Air (13-inch, M5)", + "Mac17,4": "MacBook Air (15-inch, M5)", + "Mac17,5": "MacBook Neo", + "Mac17,6": "MacBook Pro (16-inch, M5 Max)", + "Mac17,7": "MacBook Pro (14-inch, M5 Max)", + "Mac17,8": "MacBook Pro (16-inch, M5 Pro)", + "Mac17,9": "MacBook Pro (14-inch, M5 Pro)", + "MacBook1,1": "MacBook (13-inch, Mid 2006)", + "MacBook10,1": "MacBook (Retina, 12-inch, 2017)", + "MacBook2,1": "MacBook (13-inch, Late 2006)", + "MacBook3,1": "MacBook (13-inch, Late 2007)", + "MacBook4,1": "MacBook (13-inch, Early 2008)", + "MacBook5,1": "MacBook (13-inch, Aluminum, Late 2008)", + "MacBook5,2": "MacBook (13-inch, Mid 2009)", + "MacBook6,1": "MacBook (13-inch, Late 2009)", + "MacBook7,1": "MacBook (13-inch, Mid 2010)", + "MacBook8,1": "MacBook (Retina, 12-inch, Early 2015)", + "MacBook9,1": "MacBook (Retina, 12-inch, Early 2016)", + "MacBookAir1,1": "MacBook Air (Early 2008)", + "MacBookAir10,1": "MacBook Air (M1, 2020)", + "MacBookAir2,1": "MacBook Air (Mid 2009)", + "MacBookAir3,1": "MacBook Air (11-inch, Late 2010)", + "MacBookAir3,2": "MacBook Air (13-inch, Late 2010)", + "MacBookAir4,1": "MacBook Air (11-inch, Mid 2011)", + "MacBookAir4,2": "MacBook Air (13-inch, Mid 2011)", + "MacBookAir5,1": "MacBook Air (11-inch, Mid 2012)", + "MacBookAir5,2": "MacBook Air (13-inch, Mid 2012)", + "MacBookAir6,1": "MacBook Air (11-inch, Mid 2013) / (11-inch, Early 2014)", + "MacBookAir6,2": "MacBook Air (13-inch, Mid 2013) / (13-inch, Early 2014)", + "MacBookAir7,1": "MacBook Air (11-inch, Early 2015)", + "MacBookAir7,2": "MacBook Air (13-inch, Early 2015) / (13-inch, 2017)", + "MacBookAir8,1": "MacBook Air (Retina, 13-inch, 2018)", + "MacBookAir8,2": "MacBook Air (Retina, 13-inch, 2019)", + "MacBookAir9,1": "MacBook Air (Retina, 13-inch, 2020)", + "MacBookPro1,1": "MacBook Pro (15-inch, Early 2006)", + "MacBookPro1,2": "MacBook Pro (17-inch, Early 2006)", + "MacBookPro10,1": "MacBook Pro (Retina, 15-inch, Mid 2012/Early 2013)", + "MacBookPro10,2": "MacBook Pro (Retina, 13-inch, Late 2012/Early 2013)", + "MacBookPro11,1": "MacBook Pro (Retina, 13-inch, Late 2013/Mid 2014)", + "MacBookPro11,2": "MacBook Pro (Retina, 15-inch, Late 2013/Mid 2014)", + "MacBookPro11,3": "MacBook Pro (Retina, 15-inch, Late 2013/Mid 2014)", + "MacBookPro11,4": "MacBook Pro (Retina, 15-inch, Mid 2015)", + "MacBookPro11,5": "MacBook Pro (Retina, 15-inch, Mid 2015)", + "MacBookPro12,1": "MacBook Pro (Retina, 13-inch, Early 2015)", + "MacBookPro13,1": "MacBook Pro (13-inch, 2016)", + "MacBookPro13,2": "MacBook Pro (13-inch, 2016)", + "MacBookPro13,3": "MacBook Pro (15-inch, 2016)", + "MacBookPro14,1": "MacBook Pro (13-inch, 2017)", + "MacBookPro14,2": "MacBook Pro (13-inch, 2017)", + "MacBookPro14,3": "MacBook Pro (15-inch, 2017)", + "MacBookPro15,1": "MacBook Pro (15-inch, 2018/2019)", + "MacBookPro15,2": "MacBook Pro (13-inch, 2018/2019)", + "MacBookPro15,3": "MacBook Pro (15-inch, 2019)", + "MacBookPro15,4": "MacBook Pro (13-inch, 2019)", + "MacBookPro16,1": "MacBook Pro (16-inch, 2019)", + "MacBookPro16,2": "MacBook Pro (13-inch, 2020)", + "MacBookPro16,3": "MacBook Pro (13-inch, 2020)", + "MacBookPro16,4": "MacBook Pro (16-inch, 2019)", + "MacBookPro17,1": "MacBook Pro (13-inch, M1, 2020)", + "MacBookPro18,1": "MacBook Pro (16-inch, 2021)", + "MacBookPro18,2": "MacBook Pro (16-inch, 2021)", + "MacBookPro18,3": "MacBook Pro (14-inch, 2021)", + "MacBookPro18,4": "MacBook Pro (14-inch, 2021)", + "MacBookPro2,1": "MacBook Pro (17-inch, Late 2006)", + "MacBookPro2,2": "MacBook Pro (15-inch, Late 2006)", + "MacBookPro3,1": "MacBook Pro (15-inch, Mid 2007)", + "MacBookPro4,1": "MacBook Pro (Early 2008)", + "MacBookPro5,1": "MacBook Pro (Late 2008)", + "MacBookPro5,2": "MacBook Pro (Early/Mid 2009)", + "MacBookPro5,3": "MacBook Pro (Mid 2009)", + "MacBookPro5,4": "MacBook Pro (15-inch, Mid 2009, Integrated Graphics)", + "MacBookPro5,5": "MacBook Pro (13-inch, Mid 2009)", + "MacBookPro6,1": "MacBook Pro (17-inch, Mid 2010)", + "MacBookPro6,2": "MacBook Pro (15-inch, Mid 2010)", + "MacBookPro7,1": "MacBook Pro (13-inch, Mid 2010)", + "MacBookPro8,1": "MacBook Pro (13-inch, Early/Late 2011)", + "MacBookPro8,2": "MacBook Pro (15-inch, Early/Late 2011)", + "MacBookPro8,3": "MacBook Pro (17-inch, Early/Late 2011)", + "MacBookPro9,1": "MacBook Pro (15-inch, Mid 2012)", + "MacBookPro9,2": "MacBook Pro (13-inch, Mid 2012)", + "MacPro1,1": "Mac Pro (Mid 2006)", + "MacPro2,1": "Mac Pro (Early 2007)", + "MacPro3,1": "Mac Pro (Early 2008)", + "MacPro4,1": "Mac Pro (Early 2009)", + "MacPro5,1": "Mac Pro (Mid 2010/Mid 2012)", + "MacPro6,1": "Mac Pro (Late 2013)", + "MacPro7,1": "Mac Pro (2019)", + "Macmini1,1": "Mac Mini (2006)", + "Macmini2,1": "Mac Mini (2007)", + "Macmini3,1": "Mac Mini (2009)", + "Macmini4,1": "Mac Mini (Mid 2010)", + "Macmini5,1": "Mac Mini (Mid 2011)", + "Macmini5,2": "Mac Mini (Mid 2011)", + "Macmini5,3": "Mac Mini (Mid 2011)", + "Macmini6,1": "Mac Mini (Late 2012)", + "Macmini6,2": "Mac Mini (Late 2012)", + "Macmini7,1": "Mac Mini (Late 2014)", + "Macmini8,1": "Mac Mini (2018)", + "Macmini9,1": "Mac Mini M1 (2020)", + "RealityDevice14,1": "Vision Pro", + "RealityDevice17,1": "Vision Pro (M5)", + "VirtualMac1,1": "Apple Virtual Machine", + "VirtualMac2,1": "Apple Virtual Machine", + "VirtualMac2,3": "Apple Virtual Machine", + "Watch1,1": "Apple Watch (1st generation) (38mm)", + "Watch1,2": "Apple Watch (1st generation) (42mm)", + "Watch2,3": "Apple Watch Series 2 (38mm)", + "Watch2,4": "Apple Watch Series 2 (42mm)", + "Watch2,6": "Apple Watch Series 1 (38mm)", + "Watch2,7": "Apple Watch Series 1 (42mm)", + "Watch3,1": "Apple Watch Series 3 (GPS + Cellular, 38mm)", + "Watch3,2": "Apple Watch Series 3 (GPS + Cellular, 42mm)", + "Watch3,3": "Apple Watch Series 3 (GPS, 38mm)", + "Watch3,4": "Apple Watch Series 3 (GPS, 42mm)", + "Watch4,1": "Apple Watch Series 4 (GPS, 40mm)", + "Watch4,2": "Apple Watch Series 4 (GPS, 44mm)", + "Watch4,3": "Apple Watch Series 4 (GPS + Cellular, 40mm)", + "Watch4,4": "Apple Watch Series 4 (GPS + Cellular, 44mm)", + "Watch5,1": "Apple Watch Series 5 (GPS, 40mm)", + "Watch5,10": "Apple Watch SE (1st generation, GPS, 44mm)", + "Watch5,11": "Apple Watch SE (1st generation, GPS + Cellular, 40mm)", + "Watch5,12": "Apple Watch SE (1st generation, GPS + Cellular, 44mm)", + "Watch5,2": "Apple Watch Series 5 (GPS, 44mm)", + "Watch5,3": "Apple Watch Series 5 (GPS + Cellular, 40mm)", + "Watch5,4": "Apple Watch Series 5 (GPS + Cellular, 44mm)", + "Watch5,9": "Apple Watch SE (1st generation, GPS, 40mm)", + "Watch6,1": "Apple Watch Series 6 (GPS, 40mm)", + "Watch6,10": "Apple Watch SE (2nd generation, GPS, 40mm)", + "Watch6,11": "Apple Watch SE (2nd generation, GPS, 44mm)", + "Watch6,12": "Apple Watch SE (2nd generation, GPS + Cellular, 40mm)", + "Watch6,13": "Apple Watch SE (2nd generation, GPS + Cellular, 44mm)", + "Watch6,14": "Apple Watch Series 8 (GPS, 45mm)", + "Watch6,15": "Apple Watch Series 8 (GPS, 41mm)", + "Watch6,16": "Apple Watch Series 8 (GPS + Cellular, 41mm)", + "Watch6,17": "Apple Watch Series 8 (GPS + Cellular, 45mm)", + "Watch6,18": "Apple Watch Ultra", + "Watch6,2": "Apple Watch Series 6 (GPS, 44mm)", + "Watch6,3": "Apple Watch Series 6 (GPS + Cellular, 40mm)", + "Watch6,4": "Apple Watch Series 6 (GPS + Cellular, 44mm)", + "Watch6,6": "Apple Watch Series 7 (GPS, 41mm)", + "Watch6,7": "Apple Watch Series 7 (GPS, 45mm)", + "Watch6,8": "Apple Watch Series 7 (GPS + Cellular, 41mm)", + "Watch6,9": "Apple Watch Series 7 (GPS + Cellular, 45mm)", + "Watch7,1": "Apple Watch Series 9 (GPS, 41mm)", + "Watch7,10": "Apple Watch Series 10 (GPS + Cellular, 42mm)", + "Watch7,11": "Apple Watch Series 10 (GPS + Cellular, 46mm)", + "Watch7,12": "Apple Watch Ultra 3", + "Watch7,13": "Apple Watch SE 3 (GPS, 40mm)", + "Watch7,14": "Apple Watch SE 3 (GPS, 44mm)", + "Watch7,15": "Apple Watch SE 3 (GPS + Cellular, 40mm)", + "Watch7,16": "Apple Watch SE 3 (GPS + Cellular, 44mm)", + "Watch7,17": "Apple Watch Series 11 (GPS, 42mm)", + "Watch7,18": "Apple Watch Series 11 (GPS, 46mm)", + "Watch7,19": "Apple Watch Series 11 (GPS + Cellular, 42mm)", + "Watch7,2": "Apple Watch Series 9 (GPS, 45mm)", + "Watch7,20": "Apple Watch Series 11 (GPS + Cellular, 46mm)", + "Watch7,3": "Apple Watch Series 9 (GPS + Cellular, 41mm)", + "Watch7,4": "Apple Watch Series 9 (GPS + Cellular, 45mm)", + "Watch7,5": "Apple Watch Ultra 2", + "Watch7,8": "Apple Watch Series 10 (GPS, 42mm)", + "Watch7,9": "Apple Watch Series 10 (GPS, 46mm)", + "Xserve1,1": "Xserve (Late 2006)", + "Xserve2,1": "Xserve (Early 2008)", + "Xserve3,1": "Xserve (Early 2009)", + "iMac10,1": "iMac (21.5-inch, Late 2009)", + "iMac11,1": "iMac (27-inch, Late 2009, Core i5/i7)", + "iMac11,2": "iMac (21.5-inch, Mid 2010)", + "iMac11,3": "iMac (27-inch, Mid 2010)", + "iMac12,1": "iMac (21.5-inch, Mid 2011)", + "iMac12,2": "iMac (27-inch, Mid 2011)", + "iMac13,1": "iMac (21.5-inch, Late 2012)", + "iMac13,2": "iMac (27-inch, Late 2012)", + "iMac13,3": "iMac (21.5-inch, Early 2013)", + "iMac14,1": "iMac (21.5-inch, Late 2013, Integrated Graphics)", + "iMac14,2": "iMac (27-inch, Late 2013)", + "iMac14,3": "iMac (21.5-inch, Late 2013, Dedicated Graphics)", + "iMac14,4": "iMac (21.5-inch, Mid 2014)", + "iMac15,1": "iMac (Retina 5K, 27-inch, Mid 2015)", + "iMac16,1": "iMac (21.5-inch, Late 2015)", + "iMac16,2": "iMac (21.5-inch, Late 2015)", + "iMac17,1": "iMac (Retina 5K, 27-inch, Late 2015)", + "iMac18,1": "iMac (21.5-inch, 2017)", + "iMac18,2": "iMac (Retina 4K, 21.5-inch, 2017)", + "iMac18,3": "iMac (Retina 5K, 27-inch, 2017)", + "iMac19,1": "iMac (Retina 5K, 27-inch, 2019)", + "iMac19,2": "iMac (Retina 4K, 21.5-inch, 2019)", + "iMac20,1": "iMac (Retina 5K, 27-inch, 2020)", + "iMac20,2": "iMac (Retina 5K, 27-inch, 2020, RX 5700/XT)", + "iMac21,1": "iMac (24-inch, M1, 2021)", + "iMac21,2": "iMac (24-inch, M1, 2021)", + "iMac4,1": "iMac (17-inch, Early 2006)", + "iMac4,2": "iMac (17-inch, Mid 2006)", + "iMac5,1": "iMac (17-inch, Late 2006, Dedicated Graphics)", + "iMac5,2": "iMac (17-inch, Late 2006, Integrated Graphics)", + "iMac6,1": "iMac (24-inch, Late 2006)", + "iMac7,1": "iMac (24-inch, Mid 2007)", + "iMac8,1": "iMac (24-inch, Early 2008)", + "iMac9,1": "iMac (20-inch, Mid 2009)", + "iMacPro1,1": "iMac Pro (Retina 5K, 27-inch, Late 2017)", + "iPad1,1": "iPad", + "iPad11,1": "iPad mini (5th generation) Wi-Fi", + "iPad11,2": "iPad mini (5th generation) Wi-Fi + Cellular", + "iPad11,3": "iPad Air (3rd generation) Wi-Fi", + "iPad11,4": "iPad Air (3rd generation) Wi-Fi + Cellular", + "iPad11,6": "iPad (8th generation) Wi-Fi", + "iPad11,7": "iPad (8th generation) Wi-Fi + Cellular", + "iPad12,1": "iPad (9th generation) Wi-Fi", + "iPad12,2": "iPad (9th generation) Wi-Fi + Cellular", + "iPad13,1": "iPad Air (4th generation) Wi-Fi", + "iPad13,10": "iPad Pro 12.9-inch (5th generation) Wi-Fi + Cellular", + "iPad13,11": "iPad Pro 12.9-inch (5th generation) Wi-Fi + Cellular (1 or 2 TB)", + "iPad13,16": "iPad Air (5th generation) Wi-Fi", + "iPad13,17": "iPad Air (5th generation) Wi-Fi + Cellular", + "iPad13,18": "iPad (10th generation) Wi-Fi", + "iPad13,19": "iPad (10th generation) Wi-Fi + Cellular", + "iPad13,2": "iPad Air (4th generation) Wi-Fi + Cellular", + "iPad13,4": "iPad Pro 11-inch (3rd generation) Wi-Fi", + "iPad13,5": "iPad Pro 11-inch (3rd generation) Wi-Fi (1 or 2 TB)", + "iPad13,6": "iPad Pro 11-inch (3rd generation) Wi-Fi + Cellular", + "iPad13,7": "iPad Pro 11-inch (3rd generation) Wi-Fi + Cellular (1 or 2 TB)", + "iPad13,8": "iPad Pro 12.9-inch (5th generation) Wi-Fi", + "iPad13,9": "iPad Pro 12.9-inch (5th generation) Wi-Fi (1 or 2 TB)", + "iPad14,1": "iPad mini (6th generation) Wi-Fi", + "iPad14,10": "iPad Air 13-inch (M2) Wi-Fi", + "iPad14,11": "iPad Air 13-inch (M2) Wi-Fi + Cellular", + "iPad14,2": "iPad mini (6th generation) Wi-Fi + Cellular", + "iPad14,3": "iPad Pro 11-inch (4th generation) Wi-Fi", + "iPad14,4": "iPad Pro 11-inch (4th generation) Wi-Fi + Cellular", + "iPad14,5": "iPad Pro 12.9-inch (6th generation) Wi-Fi", + "iPad14,6": "iPad Pro 12.9-inch (6th generation) Wi-Fi + Cellular", + "iPad14,8": "iPad Air 11-inch (M2) Wi-Fi", + "iPad14,9": "iPad Air 11-inch (M2) Wi-Fi + Cellular", + "iPad15,3": "iPad Air 11-inch (M3) Wi-Fi", + "iPad15,4": "iPad Air 11-inch (M3) Wi-Fi + Cellular", + "iPad15,5": "iPad Air 13-inch (M3) Wi-Fi", + "iPad15,6": "iPad Air 13-inch (M3) Wi-Fi + Cellular", + "iPad15,7": "iPad (A16) Wi-Fi", + "iPad15,8": "iPad (A16) Wi-Fi + Cellular", + "iPad16,1": "iPad mini (A17 Pro) Wi-Fi", + "iPad16,10": "iPad Air 13-inch (M4) Wi-Fi", + "iPad16,11": "iPad Air 13-inch (M4) Wi-Fi + Cellular", + "iPad16,2": "iPad mini (A17 Pro) Wi-Fi + Cellular", + "iPad16,3": "iPad Pro 11-inch (M4) Wi-Fi", + "iPad16,4": "iPad Pro 11-inch (M4) Wi-Fi + Cellular", + "iPad16,5": "iPad Pro 13-inch (M4) Wi-Fi", + "iPad16,6": "iPad Pro 13-inch (M4) Wi-Fi + Cellular", + "iPad16,8": "iPad Air 11-inch (M4) Wi-Fi", + "iPad16,9": "iPad Air 11-inch (M4) Wi-Fi + Cellular", + "iPad17,1": "iPad Pro 11-inch Wi-Fi (M5)", + "iPad17,2": "iPad Pro 11-inch Wi-Fi + Cellular (M5)", + "iPad17,3": "iPad Pro 13-inch Wi-Fi (M5)", + "iPad17,4": "iPad Pro 13-inch Wi-Fi + Cellular (M5)", + "iPad2,1": "iPad 2 Wi-Fi", + "iPad2,2": "iPad 2 Wi-Fi + 3G (GSM)", + "iPad2,3": "iPad 2 Wi-Fi + 3G (CDMA)", + "iPad2,4": "iPad 2 Wi-Fi (Mid 2012)", + "iPad2,5": "iPad mini Wi-Fi", + "iPad2,6": "iPad mini Wi-Fi + Cellular", + "iPad2,7": "iPad mini Wi-Fi + Cellular (MM)", + "iPad3,1": "iPad (3rd generation) Wi-Fi", + "iPad3,2": "iPad (3rd generation) Wi-Fi + Cellular (VZ)", + "iPad3,3": "iPad (3rd generation) Wi-Fi + Cellular", + "iPad3,4": "iPad (4th generation) Wi-Fi", + "iPad3,5": "iPad (4th generation) Wi-Fi + Cellular", + "iPad3,6": "iPad (4th generation) Wi-Fi + Cellular (MM)", + "iPad4,1": "iPad Air Wi-Fi", + "iPad4,2": "iPad Air Wi-Fi + Cellular (GSM/CDMA)", + "iPad4,3": "iPad Air Wi-Fi + Cellular (TD-LTE)", + "iPad4,4": "iPad mini 2 Wi-Fi", + "iPad4,5": "iPad mini 2 Wi-Fi + Cellular", + "iPad4,6": "iPad mini 2 Wi-Fi + Cellular (TD-LTE)", + "iPad4,7": "iPad mini 3 Wi-Fi", + "iPad4,8": "iPad mini 3 Wi-Fi + Cellular", + "iPad4,9": "iPad mini 3 Wi-Fi + Cellular (TD-LTE)", + "iPad5,1": "iPad mini 4 Wi-Fi", + "iPad5,2": "iPad mini 4 Wi-Fi + Cellular", + "iPad5,3": "iPad Air 2 Wi-Fi", + "iPad5,4": "iPad Air 2 Wi-Fi + Cellular", + "iPad6,11": "iPad (5th generation) Wi-Fi", + "iPad6,12": "iPad (5th generation) Wi-Fi + Cellular", + "iPad6,3": "iPad Pro (9.7-inch) Wi-Fi", + "iPad6,4": "iPad Pro (9.7-inch) Wi-Fi + Cellular", + "iPad6,7": "iPad Pro (12.9-inch) (1st generation) Wi-Fi", + "iPad6,8": "iPad Pro (12.9-inch) (1st generation) Wi-Fi + Cellular", + "iPad7,1": "iPad Pro 12.9-inch (2nd generation) Wi-Fi", + "iPad7,11": "iPad (7th generation) Wi-Fi", + "iPad7,12": "iPad (7th generation) Wi-Fi + Cellular", + "iPad7,2": "iPad Pro 12.9-inch (2nd generation) Wi-Fi + Cellular", + "iPad7,3": "iPad Pro (10.5-inch) Wi-Fi", + "iPad7,4": "iPad Pro (10.5-inch) Wi-Fi + Cellular", + "iPad7,5": "iPad (6th generation) Wi-Fi", + "iPad7,6": "iPad (6th generation) Wi-Fi + Cellular", + "iPad8,1": "iPad Pro 11-inch (1st generation) Wi-Fi", + "iPad8,10": "iPad Pro 11-inch (2nd generation) Wi-Fi + Cellular", + "iPad8,11": "iPad Pro 12.9-inch (4th generation) Wi-Fi", + "iPad8,12": "iPad Pro 12.9-inch (4th generation) Wi-Fi + Cellular", + "iPad8,2": "iPad Pro 11-inch (1st generation) Wi-Fi (1TB)", + "iPad8,3": "iPad Pro 11-inch (1st generation) Wi-Fi + Cellular", + "iPad8,4": "iPad Pro 11-inch (1st generation) Wi-Fi + Cellular (1TB)", + "iPad8,5": "iPad Pro 12.9-inch (3rd generation) Wi-Fi", + "iPad8,6": "iPad Pro 12.9-inch (3rd generation) Wi-Fi (1TB)", + "iPad8,7": "iPad Pro 12.9-inch (3rd generation) Wi-Fi + Cellular", + "iPad8,8": "iPad Pro 12.9-inch (3rd generation) Wi-Fi + Cellular (1TB)", + "iPad8,9": "iPad Pro 11-inch (2nd generation) Wi-Fi", + "iPhone1,1": "iPhone", + "iPhone1,2": "iPhone 3G", + "iPhone10,1": "iPhone 8 (CDMA)", + "iPhone10,2": "iPhone 8 Plus (CDMA)", + "iPhone10,3": "iPhone X (CDMA)", + "iPhone10,4": "iPhone 8 (GSM)", + "iPhone10,5": "iPhone 8 Plus (GSM)", + "iPhone10,6": "iPhone X (GSM)", + "iPhone11,2": "iPhone XS", + "iPhone11,4": "iPhone XS Max (China mainland)", + "iPhone11,6": "iPhone XS Max", + "iPhone11,8": "iPhone XR", + "iPhone12,1": "iPhone 11", + "iPhone12,3": "iPhone 11 Pro", + "iPhone12,5": "iPhone 11 Pro Max", + "iPhone12,8": "iPhone SE (2nd generation)", + "iPhone13,1": "iPhone 12 mini", + "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", + "iPhone13,4": "iPhone 12 Pro Max", + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,6": "iPhone SE (3rd generation)", + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "iPhone17,5": "iPhone 16e", + "iPhone18,1": "iPhone 17 Pro", + "iPhone18,2": "iPhone 17 Pro Max", + "iPhone18,3": "iPhone 17", + "iPhone18,4": "iPhone Air", + "iPhone18,5": "iPhone 17e", + "iPhone2,1": "iPhone 3GS", + "iPhone3,1": "iPhone 4 (GSM)", + "iPhone3,2": "iPhone 4 (GSM, 2012)", + "iPhone3,3": "iPhone 4 (CDMA)", + "iPhone4,1": "iPhone 4s", + "iPhone5,1": "iPhone 5 (GSM)", + "iPhone5,2": "iPhone 5 (CDMA)", + "iPhone5,3": "iPhone 5c (GSM)", + "iPhone5,4": "iPhone 5c (CDMA)", + "iPhone6,1": "iPhone 5s (GSM)", + "iPhone6,2": "iPhone 5s (CDMA)", + "iPhone7,1": "iPhone 6 Plus", + "iPhone7,2": "iPhone 6", + "iPhone8,1": "iPhone 6s", + "iPhone8,2": "iPhone 6s Plus", + "iPhone8,4": "iPhone SE (1st generation)", + "iPhone9,1": "iPhone 7 (CDMA)", + "iPhone9,2": "iPhone 7 Plus (CDMA)", + "iPhone9,3": "iPhone 7 (GSM)", + "iPhone9,4": "iPhone 7 Plus (GSM)", + "iPod1,1": "iPod touch (1st generation)", + "iPod2,1": "iPod touch (2nd generation)", + "iPod3,1": "iPod touch (3rd generation)", + "iPod4,1": "iPod touch (4th generation)", + "iPod5,1": "iPod touch (5th generation)", + "iPod7,1": "iPod touch (6th generation)", + "iPod9,1": "iPod touch (7th generation)", +} diff --git a/server/fleet/hostresponse.go b/server/fleet/hostresponse.go index 85e345d365..ef7605aec1 100644 --- a/server/fleet/hostresponse.go +++ b/server/fleet/hostresponse.go @@ -12,14 +12,15 @@ type HostResponse struct { *Host // Add alias fields for team name and ID for use in CSV reports. // TODO: clean up in Fleet 5. - FleetID *uint `json:"-" csv:"fleet_id"` - FleetName *string `json:"-" csv:"fleet_name"` - Status HostStatus `json:"status" csv:"status"` - DisplayText string `json:"display_text" csv:"display_text"` - DisplayName string `json:"display_name" csv:"display_name"` - Labels []*Label `json:"labels,omitempty" csv:"-"` - Geolocation *GeoLocation `json:"geolocation,omitempty" csv:"-"` - CSVDeviceMapping string `json:"-" db:"-" csv:"device_mapping"` + FleetID *uint `json:"-" csv:"fleet_id"` + FleetName *string `json:"-" csv:"fleet_name"` + Status HostStatus `json:"status" csv:"status"` + DisplayText string `json:"display_text" csv:"display_text"` + DisplayName string `json:"display_name" csv:"display_name"` + Labels []*Label `json:"labels,omitempty" csv:"-"` + Geolocation *GeoLocation `json:"geolocation,omitempty" csv:"-"` + CSVDeviceMapping string `json:"-" db:"-" csv:"device_mapping"` + HardwareMarketingName string `json:"hardware_marketing_name" csv:"hardware_marketing_name"` } // HostResponseForHost returns a HostResponse from Host with Geolocation. @@ -32,10 +33,11 @@ func HostResponseForHost(ctx context.Context, svc Service, host *Host) *HostResp // HostResponseForHostCheap returns a new HostResponse from a Host without computing Geolocation. func HostResponseForHostCheap(host *Host) *HostResponse { return &HostResponse{ - Host: host, - Status: host.Status(time.Now()), - DisplayText: host.Hostname, - DisplayName: host.DisplayName(), + Host: host, + Status: host.Status(time.Now()), + DisplayText: host.Hostname, + DisplayName: host.DisplayName(), + HardwareMarketingName: host.HardwareMarketingName(), } } @@ -53,8 +55,9 @@ func HostResponsesForHostsCheap(hosts []Host) []HostResponse { // with the HostDetail details. type HostDetailResponse struct { HostDetail - Status HostStatus `json:"status"` - DisplayText string `json:"display_text"` - DisplayName string `json:"display_name"` - Geolocation *GeoLocation `json:"geolocation,omitempty"` + Status HostStatus `json:"status"` + DisplayText string `json:"display_text"` + DisplayName string `json:"display_name"` + Geolocation *GeoLocation `json:"geolocation,omitempty"` + HardwareMarketingName string `json:"hardware_marketing_name"` } diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 3ededef63a..e94d56361c 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -1082,6 +1082,19 @@ func (h *Host) DisplayName() string { return HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial) } +// HardwareMarketingName returns the Apple marketing name for the host's hardware +// model (e.g. "MacBook Pro (16-inch, Nov 2023)"). It returns an empty string +// when the platform is not an Apple platform or the identifier is not in the +// mapping, so a missing mapping entry can be told apart from the raw model. +func (h *Host) HardwareMarketingName() string { + if IsApplePlatform(h.Platform) { + if name, ok := AppleHardwareModelsToMarketingNames[h.HardwareModel]; ok { + return name + } + } + return "" +} + func (h *HostLite) DisplayName() string { return HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial) } diff --git a/server/service/hosts.go b/server/service/hosts.go index 315ed02ffb..f62886f4e3 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "iter" "net/http" "reflect" @@ -74,11 +73,12 @@ func hostDetailResponseForHost(ctx context.Context, svc fleet.Service, host *fle } return &fleet.HostDetailResponse{ - HostDetail: *host, - Status: host.Status(time.Now()), - DisplayText: host.Hostname, - DisplayName: host.DisplayName(), - Geolocation: geoLoc, + HostDetail: *host, + Status: host.Status(time.Now()), + DisplayText: host.Hostname, + DisplayName: host.DisplayName(), + Geolocation: geoLoc, + HardwareMarketingName: host.HardwareMarketingName(), }, nil } @@ -3089,40 +3089,41 @@ func (r hostsReportResponse) HijackRender(ctx context.Context, w http.ResponseWr return } + // read back the CSV to reorder and (optionally) filter columns + recs, err := csv.NewReader(&buf).ReadAll() + if err != nil { + logging.WithErr(ctx, err) + encodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w) + return + } + returnAll := len(r.Columns) == 0 var outRows [][]string - if !returnAll { - // read back the CSV to filter out any unwanted columns - recs, err := csv.NewReader(&buf).ReadAll() - if err != nil { - logging.WithErr(ctx, err) - encodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w) - return + if returnAll { + applyCSVColumnPlacements(recs) + outRows = recs + } else if len(recs) > 0 { + // map the header names to their field index + hdrs := make(map[string]int, len(recs[0])) + for i, hdr := range recs[0] { + hdrs[hdr] = i } - if len(recs) > 0 { - // map the header names to their field index - hdrs := make(map[string]int, len(recs)) - for i, hdr := range recs[0] { - hdrs[hdr] = i - } - - outRows = make([][]string, len(recs)) - for i, rec := range recs { - for _, col := range r.Columns { - colIx, ok := hdrs[col] - if !ok { - // invalid column name - it would be nice to catch this in the - // endpoint before processing the results, but it would require - // duplicating the list of columns from the Host's struct tags to a - // map and keep this in sync, for what is essentially a programmer - // mistake that should be caught and corrected early. - encodeError(ctx, &fleet.BadRequestError{Message: fmt.Sprintf("invalid column name: %q", col)}, w) - return - } - outRows[i] = append(outRows[i], rec[colIx]) + outRows = make([][]string, len(recs)) + for i, rec := range recs { + for _, col := range r.Columns { + colIx, ok := hdrs[col] + if !ok { + // invalid column name - it would be nice to catch this in the + // endpoint before processing the results, but it would require + // duplicating the list of columns from the Host's struct tags to a + // map and keep this in sync, for what is essentially a programmer + // mistake that should be caught and corrected early. + encodeError(ctx, &fleet.BadRequestError{Message: fmt.Sprintf("invalid column name: %q", col)}, w) + return } + outRows[i] = append(outRows[i], rec[colIx]) } } } @@ -3132,17 +3133,69 @@ func (r hostsReportResponse) HijackRender(ctx context.Context, w http.ResponseWr w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) - var err error - if returnAll { - _, err = io.Copy(w, &buf) - } else { - err = csv.NewWriter(w).WriteAll(outRows) - } - if err != nil { + if err := csv.NewWriter(w).WriteAll(outRows); err != nil { logging.WithErr(ctx, err) } } +// csvColumnPlacements forces the ordering of columns in the full (unfiltered) +// hosts report CSV. gocsv appends HostResponse fields after all Host columns, +// so columns whose documented position is elsewhere must be moved explicitly. +// The filtered path already emits columns in the requested order. +var csvColumnPlacements = []struct{ col, after string }{ + {"hardware_marketing_name", "hardware_model"}, +} + +func applyCSVColumnPlacements(recs [][]string) { + for _, p := range csvColumnPlacements { + reorderCSVColumnAfter(recs, p.col, p.after) + } +} + +// reorderCSVColumnAfter moves the column named col so that it immediately +// follows the column named afterCol in every record (header + rows). It is a +// no-op if either column is missing. +func reorderCSVColumnAfter(recs [][]string, col, afterCol string) { + if len(recs) == 0 { + return + } + + from, after := -1, -1 + for i, hdr := range recs[0] { + switch hdr { + case col: + from = i + case afterCol: + after = i + } + } + if from < 0 || after < 0 || from == after { + return + } + + // Build the new column index order with `from` placed right after `after`. + order := make([]int, 0, len(recs[0])) + for i := range recs[0] { + if i == from { + continue + } + order = append(order, i) + if i == after { + order = append(order, from) + } + } + + for r, rec := range recs { + newRec := make([]string, len(order)) + for j, idx := range order { + if idx < len(rec) { + newRec[j] = rec[idx] + } + } + recs[r] = newRec + } +} + func hostsReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*hostsReportRequest) diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 338609c0d9..e474b619d3 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -5705,3 +5705,134 @@ func TestGetHostDEPAssignmentDetailsNotFoundClassification(t *testing.T) { }) } } + +func TestReorderCSVColumnAfter(t *testing.T) { + cases := []struct { + name string + recs [][]string + col string + afterCol string + want [][]string + }{ + { + name: "moves a column that comes after its target", + recs: [][]string{ + {"a", "model", "b", "c", "marketing"}, + {"1", "m1", "2", "3", "mk1"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"a", "model", "marketing", "b", "c"}, + {"1", "m1", "mk1", "2", "3"}, + }, + }, + { + name: "reorders every data row", + recs: [][]string{ + {"a", "model", "b", "marketing"}, + {"1", "m1", "2", "mk1"}, + {"3", "m2", "4", "mk2"}, + {"5", "m3", "6", "mk3"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"a", "model", "marketing", "b"}, + {"1", "m1", "mk1", "2"}, + {"3", "m2", "mk2", "4"}, + {"5", "m3", "mk3", "6"}, + }, + }, + { + name: "moves a column that comes before its target", + recs: [][]string{ + {"marketing", "a", "model", "b"}, + {"mk1", "1", "m1", "2"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"a", "model", "marketing", "b"}, + {"1", "m1", "mk1", "2"}, + }, + }, + { + name: "already immediately after target is unchanged", + recs: [][]string{ + {"model", "marketing", "b"}, + {"m1", "mk1", "2"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"model", "marketing", "b"}, + {"m1", "mk1", "2"}, + }, + }, + { + name: "header-only records are reordered", + recs: [][]string{ + {"a", "marketing", "model", "b"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"a", "model", "marketing", "b"}, + }, + }, + { + name: "missing col is a no-op", + recs: [][]string{ + {"model", "b"}, + {"m1", "2"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"model", "b"}, + {"m1", "2"}, + }, + }, + { + name: "missing afterCol is a no-op", + recs: [][]string{ + {"marketing", "b"}, + {"mk1", "2"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"marketing", "b"}, + {"mk1", "2"}, + }, + }, + { + name: "empty records is a no-op", + recs: [][]string{}, + col: "marketing", + afterCol: "model", + want: [][]string{}, + }, + { + name: "ragged rows are padded without panicking", + recs: [][]string{ + {"a", "model", "b", "marketing"}, + {"1", "m1"}, + }, + col: "marketing", + afterCol: "model", + want: [][]string{ + {"a", "model", "marketing", "b"}, + {"1", "m1", "", ""}, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + reorderCSVColumnAfter(c.recs, c.col, c.afterCol) + require.Equal(t, c.want, c.recs) + }) + } +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 31f96018cf..01659eac5c 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -10832,19 +10832,21 @@ func (s *integrationTestSuite) TestHostsReportDownload() { res.Body.Close() require.NoError(t, err) require.Len(t, rows, len(hosts)+1) // all hosts + header row - assert.Len(t, rows[0], 57) // total number of cols + assert.Len(t, rows[0], 58) // total number of cols // Validate that both team_id and fleet_id columns are present. assert.Contains(t, rows[0], "team_id") assert.Contains(t, rows[0], "fleet_id") assert.Contains(t, rows[0], "team_name") assert.Contains(t, rows[0], "fleet_name") + // hardware_marketing_name is emitted right after hardware_model, shifting + // every subsequent column index by one. const ( idCol = 3 - issuesCol = 46 - gigsDiskCol = 42 - pctDiskCol = 43 - gigsTotalCol = 44 + issuesCol = 47 + gigsDiskCol = 43 + pctDiskCol = 44 + gigsTotalCol = 45 ) // find the row for hosts[1], it should have issues=1 (1 failing policy) and the expected disk space @@ -10880,6 +10882,21 @@ func (s *integrationTestSuite) TestHostsReportDownload() { require.Contains(t, res.Header.Get("Content-Type"), "text/csv") require.Contains(t, res.Header.Get("X-Content-Type-Options"), "nosniff") + // requesting columns that don't include hardware_model or + // hardware_marketing_name returns exactly the requested columns and neither + // hardware field (hardware_marketing_name must not leak into the report). + res = s.DoRaw( + "GET", "/api/latest/fleet/hosts/report", nil, http.StatusOK, "format", "csv", + "columns", "hostname,uuid,platform", + ) + rows, err = csv.NewReader(res.Body).ReadAll() + res.Body.Close() + require.NoError(t, err) + require.Len(t, rows, len(hosts)+1) + require.Equal(t, []string{"hostname", "uuid", "platform"}, rows[0]) + require.NotContains(t, rows[0], "hardware_model") + require.NotContains(t, rows[0], "hardware_marketing_name") + // pagination does not apply to this endpoint, it returns the complete list of hosts res = s.DoRaw("GET", "/api/latest/fleet/hosts/report", nil, http.StatusOK, "format", "csv", "page", "1", "per_page", "2", "columns", "hostname") rows, err = csv.NewReader(res.Body).ReadAll() @@ -10982,6 +10999,61 @@ func (s *integrationTestSuite) TestHostsReportDownload() { s.DoRaw("GET", "/api/latest/fleet/hosts/report", nil, http.StatusBadRequest, "software_id", "123", "software_version_id", "456", "software_title_id", "789") } +func (s *integrationTestSuite) TestHostsReportHardwareMarketingName() { + t := s.T() + ctx := context.Background() + + newHost := func(suffix, platform, model string) *fleet.Host { + h, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: new(t.Name() + suffix), + NodeKey: new(t.Name() + suffix), + UUID: uuid.New().String(), + Hostname: t.Name() + suffix, + Platform: platform, + }) + require.NoError(t, err) + // hardware_model is not persisted by NewHost, so set it directly. + mysqltest.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { + _, err := db.ExecContext(ctx, `UPDATE hosts SET hardware_model = ? WHERE id = ?`, model, h.ID) + return err + }) + return h + } + + // Apple host whose model maps to a marketing name, plus a non-Apple host + // with no mapping. + mapped := newHost("-mapped", "darwin", "MacBookPro18,1") + unmapped := newHost("-unmapped", "ubuntu", "Standard PC") + + res := s.DoRaw( + "GET", "/api/latest/fleet/hosts/report", nil, http.StatusOK, "format", "csv", + "columns", "hostname,hardware_model,hardware_marketing_name", + ) + rows, err := csv.NewReader(res.Body).ReadAll() + res.Body.Close() + require.NoError(t, err) + require.Len(t, rows, 3) // header + 2 hosts + // columns are returned in the requested order + require.Equal(t, []string{"hostname", "hardware_model", "hardware_marketing_name"}, rows[0]) + + byHostname := make(map[string][]string, len(rows)-1) + for _, row := range rows[1:] { + byHostname[row[0]] = row + } + + // Apple host: raw model plus the mapped marketing name. + require.Equal(t, "MacBookPro18,1", byHostname[mapped.Hostname][1]) + require.Equal(t, fleet.AppleHardwareModelsToMarketingNames["MacBookPro18,1"], byHostname[mapped.Hostname][2]) + + // Non-Apple host: raw model, empty marketing name. + require.Equal(t, "Standard PC", byHostname[unmapped.Hostname][1]) + require.Empty(t, byHostname[unmapped.Hostname][2]) +} + func (s *integrationTestSuite) TestSSODisabled() { t := s.T() @@ -13229,7 +13301,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() { res.Body.Close() require.NoError(t, err) require.Len(t, rows1, len(hosts)+1) // all hosts + header row - assert.Len(t, rows1[0], 57) // total number of cols + assert.Len(t, rows1[0], 58) // total number of cols var ( idIdx int @@ -13259,7 +13331,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() { res.Body.Close() require.NoError(t, err) require.Len(t, rows2, len(hosts)+1) // all hosts + header row - assert.Len(t, rows2[0], 57) // total number of cols + assert.Len(t, rows2[0], 58) // total number of cols // Check that all hosts have 0 issues and that they match the previous call to `/hosts/report`. for i := 1; i < len(hosts)+1; i++ { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index a4a34472c6..e63727c446 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -25364,6 +25364,7 @@ func (s *integrationMDMTestSuite) TestErrorOnEnrollmentInstallProfileProducesAct require.NoError(t, apple_mdm.HandleHostMDMProfileInstallResult(ctx, s.ds, host.UUID, case4RenewCmd, &verifying, "", s.fleetSvc.NewActivity)) require.Zero(t, countRenewalActivitiesForCmd(case4RenewCmd)) } + func (s *integrationMDMTestSuite) TestInstallAllSelfServiceSoftware() { t := s.T() ctx := context.Background()