diff --git a/CODEOWNERS b/CODEOWNERS index 4a7bc630f41f..b952d8777f0f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -72,7 +72,6 @@ go.mod @fleetdm/go # # (see website/config/custom.js for DRIs of other paths not listed here) ############################################################################################## -/website/views/pages/pricing.ejs @mikermcneil # « CEO is DRI for pricing /handbook/company/pricing-features-table.yml @mikermcneil # « CEO is current DRI for features table ############################################################################################## diff --git a/articles/fleet-4.37.0.md b/articles/fleet-4.37.0.md index 0f4cf5ed80f8..f3a2c7c37bac 100644 --- a/articles/fleet-4.37.0.md +++ b/articles/fleet-4.37.0.md @@ -1,4 +1,4 @@ -# Fleet 4.37.0 | Remote script execution & Puppet support. +# Fleet 4.37.0 | Puppet support. ![Fleet 4.37.0](../website/assets/images/articles/fleet-4.37.0-1600x900@2x.png) @@ -13,11 +13,12 @@ For upgrade instructions, see our [upgrade guide](https://fleetdm.com/docs/deplo * Puppet support * Web user interface improvements + ### Vulnerability dashboard diff --git a/articles/introducing-cross-platform-script-execution.md b/articles/introducing-cross-platform-script-execution similarity index 100% rename from articles/introducing-cross-platform-script-execution.md rename to articles/introducing-cross-platform-script-execution diff --git a/assets/images/phone-home.svg b/assets/images/phone-home.svg deleted file mode 100644 index a0335a1113c3..000000000000 --- a/assets/images/phone-home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/changes/12927-disk-encryption-settings b/changes/12927-disk-encryption-settings new file mode 100644 index 000000000000..a9464b7d5bac --- /dev/null +++ b/changes/12927-disk-encryption-settings @@ -0,0 +1 @@ +* Deprecate `mdm.macos_settings.enable_disk_encryption` in favor of `mdm.enable_disk_encryption` diff --git a/changes/12932-bitlocker-api-updates b/changes/12932-bitlocker-api-updates new file mode 100644 index 000000000000..0ce9b45e8a1b --- /dev/null +++ b/changes/12932-bitlocker-api-updates @@ -0,0 +1,4 @@ +- Added `GET /mdm/disk_encryption/summary` endpoint to get the disk encryption summary for macOS and + Windows devices. +- Added `os_settings` and `os_settings_disk_encryption` filters to `GET /hosts`, `GET /hosts/count`, + `GET /api/v1/fleet/labels/{id}/hosts` endpoints to filter hosts by OS settings. diff --git a/changes/12933-bitlocker-host-details-api b/changes/12933-bitlocker-host-details-api new file mode 100644 index 000000000000..ccb11df8b74e --- /dev/null +++ b/changes/12933-bitlocker-host-details-api @@ -0,0 +1 @@ +- Added `mdm.os_settings` to `GET /api/v1/hosts/{id}` response. diff --git a/changes/14286-detail-query-overrides b/changes/14286-detail-query-overrides new file mode 100644 index 000000000000..176dde9dbda1 --- /dev/null +++ b/changes/14286-detail-query-overrides @@ -0,0 +1 @@ +* Fixed a bug that would cause live queries to stall if a detail query override was set for a team. diff --git a/changes/bug-13894-failing-policies-styling b/changes/bug-13894-failing-policies-styling new file mode 100644 index 000000000000..5bd83a7b0961 --- /dev/null +++ b/changes/bug-13894-failing-policies-styling @@ -0,0 +1 @@ +* Fix styling for host details/device user failing policies call out \ No newline at end of file diff --git a/changes/issue-13953-changes-to-controls-page-for-bitlocker b/changes/issue-13953-changes-to-controls-page-for-bitlocker new file mode 100644 index 000000000000..728d93122e02 --- /dev/null +++ b/changes/issue-13953-changes-to-controls-page-for-bitlocker @@ -0,0 +1 @@ +- change Controls/Disk Encryption and host details page to include windows bitlocker information. diff --git a/changes/issue-13954-orbit-disk-encryption-key b/changes/issue-13954-orbit-disk-encryption-key new file mode 100644 index 000000000000..82767942ec0b --- /dev/null +++ b/changes/issue-13954-orbit-disk-encryption-key @@ -0,0 +1 @@ +* Added the `POST /api/fleet/orbit/disk_encryption_key` endpoint for Windows hosts to report the bitlocker encryption key. diff --git a/changes/issue-14007-support-get-windows-encryption-key b/changes/issue-14007-support-get-windows-encryption-key new file mode 100644 index 000000000000..0705f8e974f6 --- /dev/null +++ b/changes/issue-14007-support-get-windows-encryption-key @@ -0,0 +1 @@ +* Added support to return the decrypted disk encryption key of a Windows host. diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 99f448c5da80..b1a41965af6a 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -18,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/policies" "github.com/fleetdm/fleet/v4/server/ptr" @@ -852,7 +853,7 @@ func verifyDiskEncryptionKeys( if key.UpdatedAt.After(latest) { latest = key.UpdatedAt } - if _, err := apple_mdm.DecryptBase64CMS(key.Base64Encrypted, cert.Leaf, cert.PrivateKey); err != nil { + if _, err := mdm.DecryptBase64CMS(key.Base64Encrypted, cert.Leaf, cert.PrivateKey); err != nil { undecryptable = append(undecryptable, key.HostID) continue } diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 137c409ec786..62872eb91766 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -1043,13 +1043,13 @@ spec: foo: qux name: Team1 mdm: + enable_disk_encryption: false macos_updates: minimum_version: 10.10.10 deadline: 1992-03-01 macos_settings: custom_settings: - %s - enable_disk_encryption: false secrets: - secret: BBB `, mobileConfigPath)) @@ -1061,9 +1061,9 @@ spec: require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) assert.JSONEq(t, string(json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`)), string(*savedTeam.Config.AgentOptions)) assert.Equal(t, fleet.TeamMDM{ + EnableDiskEncryption: false, MacOSSettings: fleet.MacOSSettings{ - CustomSettings: []string{mobileConfigPath}, - EnableDiskEncryption: false, + CustomSettings: []string{mobileConfigPath}, }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -1096,9 +1096,9 @@ spec: require.True(t, ds.NewJobFuncInvoked) // all left untouched, only setup assistant added assert.Equal(t, fleet.TeamMDM{ + EnableDiskEncryption: false, MacOSSettings: fleet.MacOSSettings{ - CustomSettings: []string{mobileConfigPath}, - EnableDiskEncryption: false, + CustomSettings: []string{mobileConfigPath}, }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -1128,9 +1128,9 @@ spec: require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) // all left untouched, only bootstrap package added assert.Equal(t, fleet.TeamMDM{ + EnableDiskEncryption: false, MacOSSettings: fleet.MacOSSettings{ - CustomSettings: []string{mobileConfigPath}, - EnableDiskEncryption: false, + CustomSettings: []string{mobileConfigPath}, }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -2885,7 +2885,7 @@ spec: macos_settings: enable_disk_encryption: true `, - wantErr: `Couldn't update macos_settings because MDM features aren't turned on in Fleet.`, + wantErr: `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on`, }, { desc: "app config macos_settings.enable_disk_encryption false", diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go index 60b87335cbcb..e94f145db27b 100644 --- a/cmd/fleetctl/get.go +++ b/cmd/fleetctl/get.go @@ -13,6 +13,7 @@ import ( "time" "github.com/fatih/color" + "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/pkg/secure" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/service" @@ -166,12 +167,15 @@ func (eacp enrichedAppConfigPresenter) MarshalJSON() ([]byte, error) { *fleet.VulnerabilitiesConfig } - return json.Marshal(&struct { - fleet.EnrichedAppConfig + enrichedJSON, err := json.Marshal(fleet.EnrichedAppConfig(eacp)) + if err != nil { + return nil, err + } + + extraFieldsJSON, err := json.Marshal(&struct { UpdateInterval UpdateIntervalConfigPresenter `json:"update_interval,omitempty"` Vulnerabilities VulnerabilitiesConfigPresenter `json:"vulnerabilities,omitempty"` }{ - EnrichedAppConfig: fleet.EnrichedAppConfig(eacp), UpdateInterval: UpdateIntervalConfigPresenter{ eacp.UpdateInterval.OSQueryDetail.String(), eacp.UpdateInterval.OSQueryPolicy.String(), @@ -183,6 +187,13 @@ func (eacp enrichedAppConfigPresenter) MarshalJSON() ([]byte, error) { eacp.Vulnerabilities, }, }) + if err != nil { + return nil, err + } + + // we need to marshal and combine both groups separately because + // enrichedAppConfig has a custom marshaler. + return rawjson.CombineRoots(enrichedJSON, extraFieldsJSON) } func printConfig(c *cli.Context, config interface{}) error { diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index ff117e642004..88e612a637a4 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "os" "path/filepath" "strings" @@ -168,15 +167,15 @@ func TestGetTeams(t *testing.T) { }, nil } - b, err := ioutil.ReadFile(filepath.Join("testdata", "expectedGetTeamsText.txt")) + b, err := os.ReadFile(filepath.Join("testdata", "expectedGetTeamsText.txt")) require.NoError(t, err) expectedText := string(b) - b, err = ioutil.ReadFile(filepath.Join("testdata", "expectedGetTeamsYaml.yml")) + b, err = os.ReadFile(filepath.Join("testdata", "expectedGetTeamsYaml.yml")) require.NoError(t, err) expectedYaml := string(b) - b, err = ioutil.ReadFile(filepath.Join("testdata", "expectedGetTeamsJson.json")) + b, err = os.ReadFile(filepath.Join("testdata", "expectedGetTeamsJson.json")) require.NoError(t, err) // must read each JSON value separately and compact it var buf bytes.Buffer @@ -206,8 +205,8 @@ func TestGetTeams(t *testing.T) { errBuffer.Reset() actualJSON, err := runWithErrWriter([]string{"get", "teams", "--json"}, &errBuffer) require.NoError(t, err) - require.Equal(t, expectedJson, actualJSON.String()) require.Equal(t, errBuffer.String() == expiredBanner.String(), tt.shouldHaveExpiredBanner) + require.Equal(t, expectedJson, actualJSON.String()) errBuffer.Reset() actualYaml, err := runWithErrWriter([]string{"get", "teams", "--yaml"}, &errBuffer) @@ -433,7 +432,7 @@ func TestGetHosts(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - expected, err := ioutil.ReadFile(filepath.Join("testdata", tt.goldenFile)) + expected, err := os.ReadFile(filepath.Join("testdata", tt.goldenFile)) require.NoError(t, err) expectedResults := tt.scanner(string(expected)) actualResult := tt.scanner(runAppForTest(t, tt.args)) @@ -536,7 +535,7 @@ func TestGetHostsMDM(t *testing.T) { } if tt.goldenFile != "" { - expected, err := ioutil.ReadFile(filepath.Join("testdata", tt.goldenFile)) + expected, err := os.ReadFile(filepath.Join("testdata", tt.goldenFile)) require.NoError(t, err) if ext := filepath.Ext(tt.goldenFile); ext == ".json" { // the output of --json is not a json array, but a list of diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 2bca9327cb1b..35a209176144 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -86,6 +86,7 @@ "enabled_and_configured": false, "apple_bm_default_team": "", "windows_enabled_and_configured": false, + "enable_disk_encryption": false, "macos_updates": { "minimum_version": null, "deadline": null @@ -96,8 +97,7 @@ "webhook_url": "" }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 0206e1cf7683..dc026f027caa 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -19,6 +19,7 @@ spec: enabled_and_configured: false apple_bm_default_team: "" windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" @@ -28,7 +29,6 @@ spec: deadline: null macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 632d37cc8fe7..42a6018435d1 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -44,6 +44,7 @@ "apple_bm_enabled_and_configured": false, "enabled_and_configured": false, "windows_enabled_and_configured": false, + "enable_disk_encryption": false, "macos_updates": { "minimum_version": null, "deadline": null @@ -54,8 +55,7 @@ "webhook_url": "" }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 4c835b47e864..0e5b42befeb6 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -19,6 +19,7 @@ spec: apple_bm_terms_expired: false enabled_and_configured: false windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" @@ -28,7 +29,6 @@ spec: deadline: null macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 19152a690cd9..6a99943e9440 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -24,13 +24,13 @@ "enable_software_inventory": true }, "mdm": { + "enable_disk_encryption": false, "macos_updates": { "minimum_version": null, "deadline": null }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, @@ -84,13 +84,13 @@ } }, "mdm": { + "enable_disk_encryption": false, "macos_updates": { "minimum_version": "12.3.1", "deadline": "2021-12-14" }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index 2b571ae8b5bb..a6905cf569ee 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -7,12 +7,12 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_updates: minimum_version: null deadline: null macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false @@ -36,12 +36,12 @@ spec: enable_host_users: false enable_software_inventory: false mdm: + enable_disk_encryption: false macos_updates: minimum_version: "12.3.1" deadline: "2021-12-14" macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index bcd27f522e7e..5f25121e3b91 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -19,13 +19,13 @@ spec: apple_bm_terms_expired: false enabled_and_configured: true windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" webhook_url: "" macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 0f94a3c0b5e0..4b2fd0c151e1 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -19,13 +19,13 @@ spec: apple_bm_terms_expired: false enabled_and_configured: true windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" webhook_url: "" macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: %s enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 346bbc2eb753..a3668e64b391 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -7,9 +7,9 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null enable_end_user_authentication: false @@ -27,9 +27,9 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null macos_setup_assistant: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 45f1733019c1..95e49d032146 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -7,9 +7,9 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: %s enable_end_user_authentication: false @@ -27,9 +27,9 @@ spec: enable_host_users: false enable_software_inventory: false mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: %s macos_setup_assistant: %s diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 21d9b9d8db20..8ad10fc6c51a 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -7,9 +7,9 @@ spec: enable_host_users: false enable_software_inventory: false mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null enable_end_user_authentication: false diff --git a/docs/Configuration/configuration-files/README.md b/docs/Configuration/configuration-files/README.md index 9cc12b3321ac..8b8f34b8cf62 100644 --- a/docs/Configuration/configuration-files/README.md +++ b/docs/Configuration/configuration-files/README.md @@ -529,8 +529,10 @@ Use with caution as this may break Fleet ingestion of hosts data. ```yaml features: detail_query_overrides: - # null allows to disable the "users" query from running on hosts. + # null disables the "users" query from running on hosts. users: null + # "" disables the "disk_encryption_linux" query from running on hosts. + disk_encryption_linux: "" # this replaces the hardcoded "mdm" detail query. mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;" ``` diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 7724b770b74e..ddefdb2e9a53 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -533,6 +533,7 @@ The MDM endpoints exist to support the related command-line interface sub-comman - [Complete SSO during DEP enrollment](#complete-sso-during-dep-enrollment) - [Preassign profiles to devices](#preassign-profiles-to-devices) - [Match preassigned profiles](#match-preassigned-profiles) +- [Get FileVault statistics](#get-filevault-statistics) ### Generate Apple DEP Key Pair @@ -701,6 +702,44 @@ This endpoint stores a profile to be assigned to a host at some point in the fut `Status: 204` +### Get FileVault statistics + +_Available in Fleet Premium_ + +Get aggregate status counts of disk encryption enforced on macOS hosts. + +The summary can optionally be filtered by team id. + +`GET /api/v1/fleet/mdm/apple/filevault/summary` + +#### Parameters + +| Name | Type | In | Description | +| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | +| team_id | string | query | _Available in Fleet Premium_ The team id to filter the summary. | + +#### Example + +Get aggregate status counts of Apple disk encryption profiles applying to macOS hosts enrolled to Fleet's MDM that are not assigned to any team. + +`GET /api/v1/fleet/mdm/apple/filevault/summary` + +##### Default response + +`Status: 200` + +```json +{ + "verified": 123, + "verifying": 123, + "action_required": 123, + "enforcing": 123, + "failed": 123, + "removing_enforcement": 123 +} +``` + + ### Match preassigned profiles _Available in Fleet Premium_ @@ -2291,7 +2330,9 @@ Gets all information required by Fleet Desktop, this includes things like the nu { "failing_policies_count": 3, "notifications": { - "needs_mdm_migration": true + "needs_mdm_migration": true, + "renew_enrollment_profile": false, + "enforce_bitlocker_encryption": false, }, "config": { "org_info": { @@ -2313,6 +2354,7 @@ In regards to the `notifications` key: - `needs_mdm_migration` means that the device fits all the requirements to allow the user to initiate an MDM migration to Fleet. - `renew_enrollment_profile` means that the device is currently unmanaged from MDM but should be DEP enrolled into Fleet. +- `enforce_bitlocker_encryption` applies only to Windows devices and means that it should encrypt the disk and report the encryption key back to Fleet. #### Get device's policies diff --git a/docs/Contributing/FAQ.md b/docs/Contributing/FAQ.md index 3d3f69c92bfa..c7522aa537bc 100644 --- a/docs/Contributing/FAQ.md +++ b/docs/Contributing/FAQ.md @@ -93,6 +93,7 @@ If you also have Fleetd running on hosts, it will need access to these API endpo * `/api/fleet/orbit/ping` * `/api/fleet/orbit/scripts/request` * `/api/fleet/orbit/scripts/result` +* `/api/fleet/orbit/disk_encryption_key` * `/api/osquery/log` diff --git a/docs/Get started/anatomy.md b/docs/Get started/anatomy.md index 757c9614a148..7bc314d2454b 100644 --- a/docs/Get started/anatomy.md +++ b/docs/Get started/anatomy.md @@ -12,7 +12,7 @@ Fleetctl (pronouced “fleet control”) is a CLI (command line interface) tool ## Fleetd -Fleetd is a bundle of agents provided by Fleet to gather information about your devices. Fleetd includes [osquery](https://www.osquery.io/), Orbit, and Fleet Desktop. [Docs](https://fleetdm.com/docs/using-fleet/fleet-ui). +Fleetd is a bundle of agents provided by Fleet to gather information about your devices. Fleetd includes [osquery](https://www.osquery.io/), Orbit, and Fleet Desktop. [Docs](https://fleetdm.com/docs/using-fleet/fleetd). ## Osquery Osquery is an open-source tool for gathering information about the state of any device that the osquery agent has been installed on. [Learn more](https://www.osquery.io/). diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index a40f5daa3950..d42a9f912d0a 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -1829,14 +1829,14 @@ the `software` table. | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. **Note:** Use `page` instead of `after`. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | -| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | -| additional_info_filters | string | query | A comma-delimited list of fields to include in each host's additional information object. See [Fleet Configuration Options](https://fleetdm.com/docs/using-fleet/fleetctl-cli#fleet-configuration-options) for an example configuration with hosts' additional information. Use `*` to get all stored fields. | +| after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. **Note:** Use `page` instead of `after` | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | +| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an '@', no space, etc.). | +| additional_info_filters | string | query | A comma-delimited list of fields to include in each host's additional information object. See [Fleet Configuration Options](https://fleetdm.com/docs/using-fleet/fleetctl-cli#fleet-configuration-options) for an example configuration with hosts' additional information. Use '*' to get all stored fields. | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | policy_id | integer | query | The ID of the policy to filter hosts by. | -| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. | +| policy_response | string | query | Valid options are 'passing' or 'failing'. `policy_id` must also be specified with `policy_response`. | | software_id | integer | query | The ID of the software to filter hosts by. | | os_id | integer | query | The ID of the operating system to filter hosts by. | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | @@ -1845,7 +1845,7 @@ the `software` table. | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Can be one of 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | disable_failing_policies| boolean | query | If "true", hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | @@ -1858,9 +1858,9 @@ If `software_id` is specified, an additional top-level key `"software"` is retur If `mdm_id` is specified, an additional top-level key `"mobile_device_management_solution"` is returned with the information corresponding to the `mdm_id`. -If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results. +If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings_disk_encryption` is specified, then Windows Servers are excluded from the results. -If `munki_issue_id` is specified, an additional top-level key `"munki_issue"` is returned with the information corresponding to the `munki_issue_id`. +If `munki_issue_id` is specified, an additional top-level key `munki_issue` is returned with the information corresponding to the `munki_issue_id`. If `after` is being used with `created_at` or `updated_at`, the table must be specified in `order_key`. Those columns become `h.created_at` and `h.updated_at`. @@ -1988,13 +1988,13 @@ Response payload with the `munki_issue_id` filter provided: | Name | Type | In | Description | | ----------------------- | ------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | | after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | -| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | +| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an '@', no space, etc.). | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | policy_id | integer | query | The ID of the policy to filter hosts by. | -| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. | +| policy_response | string | query | Valid options are 'passing' or 'failing'. `policy_id` must also be specified with `policy_response`. | | software_id | integer | query | The ID of the software to filter hosts by. | | os_id | integer | query | The ID of the operating system to filter hosts by. | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | @@ -2003,7 +2003,7 @@ Response payload with the `munki_issue_id` filter provided: | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Can be one of 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | @@ -2555,6 +2555,9 @@ Returns the information of the host specified using the `uuid`, `osquery_host_id "bootstrap_package_status": "installed", "detail": "" }, + "os_settings": { + "disk_encryption": null + }, "profiles": [ { "profile_id": 999, @@ -2743,6 +2746,9 @@ This is the API route used by the **My device** page in Fleet desktop to display "detail": "", "bootstrap_package_name": "test.pkg" }, + "os_settings": { + "disk_encryption": null + }, "profiles": [ { "profile_id": 999, @@ -3291,12 +3297,12 @@ requested by a web browser. | format | string | query | **Required**, must be "csv" (only supported format for now). | | columns | string | query | Comma-delimited list of columns to include in the report (returns all columns if none is specified). | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | policy_id | integer | query | The ID of the policy to filter hosts by. | -| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. **Note: If `policy_id` is specified _without_ including `policy_response`, this will also return hosts where the policy is not configured to run or failed to run.** | +| policy_response | string | query | Valid options are 'passing' or 'failing'. `policy_id` must also be specified with `policy_response`. **Note: If `policy_id` is specified _without_ including `policy_response`, this will also return hosts where the policy is not configured to run or failed to run.** | | software_id | integer | query | The ID of the software to filter hosts by. | | os_id | integer | query | The ID of the operating system to filter hosts by. | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | @@ -3304,7 +3310,7 @@ requested by a web browser. | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Can be one of 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `status`, `query` and `team_id`. | @@ -3330,7 +3336,7 @@ created_at,updated_at,id,detail_updated_at,label_updated_at,policy_updated_at,la ### Get host's disk encryption key -Requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled +For macOS, requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled in [Fleet's osquery installers](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). Requires Fleet's MDM properly [enabled and configured](https://fleetdm.com/docs/using-fleet/mdm-macos-setup). @@ -3724,21 +3730,21 @@ Returns a list of the hosts that belong to the specified label. | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | | after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, and `ipv4`. | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | disable_failing_policies | boolean | query | If "true", hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Can be one of 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | | bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | -If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results. +If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings_disk_encryption` is specified, then Windows Servers are excluded from the results. #### Example @@ -3887,8 +3893,8 @@ Add a configuration profile to enforce custom settings on macOS hosts. | Name | Type | In | Description | | ------------------------- | -------- | ---- | ------------------------------------------------------------------------- | -| profile | file | form | **Required**. The mobileconfig file containing the profile. | -| team_id | string | form | _Available in Fleet Premium_ The team id for the profile. If specified, the profile is applied to only hosts that are assigned to the specified team. If not specified, the profile is applied to only to hosts that are not assigned to any team. | +| profile | file | form | **Required**. The .mobileconfig file containing the profile. | +| team_id | string | form | _Available in Fleet Premium_ The team ID for the profile. If specified, the profile is applied to only hosts that are assigned to the specified team. If not specified, the profile is applied to only to hosts that are not assigned to any team. | #### Example @@ -3967,7 +3973,7 @@ results (i.e., only profiles that are associated with "No team" are listed). | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | _Available in Fleet Premium_ The team id to filter profiles. | +| team_id | string | query | _Available in Fleet Premium_ The team ID to filter profiles. | #### Example @@ -4090,11 +4096,11 @@ _Available in Fleet Premium_ _Available in Fleet Premium_ -Get aggregate status counts of disk encryption enforced on hosts. +Get aggregate status counts of disk encryption enforced on macOS and Windows hosts. -The summary can optionally be filtered by team id. +The summary can optionally be filtered by team ID. -`GET /api/v1/fleet/mdm/apple/filevault/summary` +`GET /api/v1/fleet/mdm/disk_encryption/summary` #### Parameters @@ -4104,9 +4110,9 @@ The summary can optionally be filtered by team id. #### Example -Get aggregate status counts of Apple disk encryption profiles applying to macOS hosts enrolled to Fleet's MDM that are not assigned to any team. +Get aggregate disk encryption status counts of macOS and Windows hosts enrolled to Fleet's MDM that are not assigned to any team. -`GET /api/v1/fleet/mdm/apple/filevault/summary` +`GET /api/v1/fleet/mdm/disk_encryption/summary` ##### Default response @@ -4114,12 +4120,12 @@ Get aggregate status counts of Apple disk encryption profiles applying to macOS ```json { - "verified": 123, - "verifying": 123, - "action_required": 123, - "enforcing": 123, - "failed": 123, - "removing_enforcement": 123 + "verified": {"macos": 123, "windows": 123}, + "verifying": {"macos": 123, "windows": 0}, + "action_required": {"macos": 123, "windows": 0}, + "enforcing": {"macos": 123, "windows": 123}, + "failed": {"macos": 123, "windows": 123}, + "removing_enforcement": {"macos": 123, "windows": 0}, } ``` @@ -4128,7 +4134,7 @@ Get aggregate status counts of Apple disk encryption profiles applying to macOS Get aggregate status counts of all macOS settings (configuraiton profiles and disk encryption) enforced on hosts. For Fleet Premium uses, the statistics can -optionally be filtered by team id. If no team id is specified, team profiles are excluded from the +optionally be filtered by team ID. If no team ID is specified, team profiles are excluded from the results (i.e., only profiles that are associated with "No team" are listed). `GET /api/v1/fleet/mdm/apple/profiles/summary` @@ -4137,7 +4143,7 @@ results (i.e., only profiles that are associated with "No team" are listed). | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | _Available in Fleet Premium_ The team id to filter profiles. | +| team_id | string | query | _Available in Fleet Premium_ The team ID to filter profiles. | #### Example @@ -4274,7 +4280,7 @@ Sets the custom MDM setup enrollment profile for a team or no team. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | integer | json | The team id this custom enrollment profile applies to, or no team if omitted. | +| team_id | integer | json | The team ID this custom enrollment profile applies to, or no team if omitted. | | name | string | json | The filename of the uploaded custom enrollment profile. | | enrollment_profile | object | json | The custom enrollment profile's json, as documented in https://developer.apple.com/documentation/devicemanagement/profile. | @@ -4310,7 +4316,7 @@ Gets the custom MDM setup enrollment profile for a team or no team. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | integer | query | The team id for which to return the custom enrollment profile, or no team if omitted. | +| team_id | integer | query | The team ID for which to return the custom enrollment profile, or no team if omitted. | #### Example @@ -4344,7 +4350,7 @@ Deletes the custom MDM setup enrollment profile assigned to a team or no team. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | integer | query | The team id for which to delete the custom enrollment profile, or no team if omitted. | +| team_id | integer | query | The team ID for which to delete the custom enrollment profile, or no team if omitted. | #### Example @@ -4439,7 +4445,7 @@ Upload a bootstrap package that will be automatically installed during DEP setup | Name | Type | In | Description | | ------- | ------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | package | file | form | **Required**. The bootstrap package installer. It must be a signed `pkg` file. | -| team_id | string | form | The team id for the package. If specified, the package will be installed to hosts that are assigned to the specified team. If not specified, the package will be installed to hosts that are not assigned to any team. | +| team_id | string | form | The team ID for the package. If specified, the package will be installed to hosts that are assigned to the specified team. If not specified, the package will be installed to hosts that are not assigned to any team. | #### Example @@ -4485,7 +4491,7 @@ Get information about a bootstrap package that was uploaded to Fleet. | Name | Type | In | Description | | ------- | ------ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| team_id | string | url | **Required** The team id for the package. Zero (0) can be specified to get information about the bootstrap package for hosts that don't belong to a team. | +| team_id | string | url | **Required** The team ID for the package. Zero (0) can be specified to get information about the bootstrap package for hosts that don't belong to a team. | | for_update | boolean | query | If set to `true`, the authorization will be for a `write` action instead of a `read`. Useful for the write-only `gitops` role when requesting the bootstrap metadata to check if the package needs to be replaced. | #### Example @@ -4523,7 +4529,7 @@ Delete a team's bootstrap package. | Name | Type | In | Description | | ------- | ------ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| team_id | string | url | **Required** The team id for the package. Zero (0) can be specified to get information about the bootstrap package for hosts that don't belong to a team. | +| team_id | string | url | **Required** The team ID for the package. Zero (0) can be specified to get information about the bootstrap package for hosts that don't belong to a team. | #### Example @@ -4570,7 +4576,7 @@ _Available in Fleet Premium_ Get aggregate status counts of bootstrap packages delivered to DEP enrolled hosts. -The summary can optionally be filtered by team id. +The summary can optionally be filtered by team ID. `GET /api/v1/fleet/mdm/apple/bootstrap/summary` @@ -4578,7 +4584,7 @@ The summary can optionally be filtered by team id. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | The team id to filter the summary. | +| team_id | string | query | The team ID to filter the summary. | #### Example @@ -5157,7 +5163,7 @@ Team policies work the same as policies, but at the team level. | Name | Type | In | Description | | ------------------ | ------- | ---- | ------------------------------------------------------------------------------------------------------------- | -| id | integer | url | Required. Defines what team id to operate on | +| id | integer | url | Required. Defines what team ID to operate on | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | #### Example @@ -5261,7 +5267,7 @@ Team policies work the same as policies, but at the team level. | Name | Type | In | Description | | ------------------ | ------- | ---- | ------------------------------------------------------------------------------------------------------------- | -| team_id | integer | url | Defines what team id to operate on | +| team_id | integer | url | Defines what team ID to operate on | | id | integer | path | **Required.** The policy's ID. | #### Example @@ -5304,7 +5310,7 @@ The semantics for creating a team policy are the same as for global policies, se | Name | Type | In | Description | | ---------- | ------- | ---- | ------------------------------------ | -| team_id | integer | url | Defines what team id to operate on. | +| team_id | integer | url | Defines what team ID to operate on. | | name | string | body | The query's name. | | query | string | body | The query in SQL. | | description | string | body | The query's description. | @@ -5366,7 +5372,7 @@ Either `query` or `query_id` must be provided. | Name | Type | In | Description | | -------- | ------- | ---- | ------------------------------------------------- | -| team_id | integer | url | Defines what team id to operate on | +| team_id | integer | url | Defines what team ID to operate on | | ids | list | body | **Required.** The IDs of the policies to delete. | #### Example @@ -6600,7 +6606,8 @@ Deletes the session specified by ID. When the user associated with the session n "epss_probability": 0.01537, "cisa_known_exploit": false, "cve_published": "2022-01-01 12:32:00", - "cve_description": "In the GNU C Library (aka glibc or libc6) before 2.28, parse_reg_exp in posix/regcomp.c misparses alternatives, which allows attackers to cause a denial of service (assertion failure and application exit) or trigger an incorrect result by attempting a regular-expression match." + "cve_description": "In the GNU C Library (aka glibc or libc6) before 2.28, parse_reg_exp in posix/regcomp.c misparses alternatives, which allows attackers to cause a denial of service (assertion failure and application exit) or trigger an incorrect result by attempting a regular-expression match.", + "resolved_in_version": "2.28" } ], "hosts_count": 1 diff --git a/docs/Using Fleet/CIS-Benchmarks.md b/docs/Using Fleet/CIS-Benchmarks.md index 5d92bc4607ad..632caf0ae665 100644 --- a/docs/Using Fleet/CIS-Benchmarks.md +++ b/docs/Using Fleet/CIS-Benchmarks.md @@ -170,127 +170,11 @@ The following CIS benchmark checks cannot be automated and must be addressed man Fleet's policies have been written against v1.12.0 of the benchmark. You can refer to the [CIS website](https://www.cisecurity.org/cis-benchmarks) for full details about this version. -### Checks that require a Group Policy Template +### Checks that require a Group Policy template -38 items require Group Policy Template in place in order to audit them. +Several items require Group Policy templates in place in order to audit them. These items are tagged with the label `CIS_group_policy_template_required` in the YAML file, and details about the required Group Policy templates can be found in each item's `resolution`. -``` -18.3.1 CIS - Ensure 'Apply UAC restrictions to local accounts on network logons' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Apply UAC restrictions to local accounts on network logons' - -18.3.2 CIS - Ensure 'Configure SMB v1 client driver' is set to 'Enabled: Disable driver (recommended)' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Configure SMB v1 client driver' - -18.3.3 CIS - Ensure 'Configure SMB v1 server' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Configure SMB v1 server' - -18.3.4 CIS - Ensure 'Enable Structured Exception Handling Overwrite Protection (SEHOP)' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Enable Structured Exception Handling Overwrite Protection (SEHOP)' - -18.3.5 CIS - Ensure 'Limits print driver installation to Administrators' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Limits print driver installation to Administrators' - -18.3.6 CIS - Ensure 'NetBT NodeType configuration' is set to 'Enabled: P-node (recommended)' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\NetBT NodeType configuration' - -18.3.7 CIS - Ensure 'WDigest Authentication' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\WDigest Authentication (disabling may require KB2871997)' - -18.4.1 CIS - Ensure 'MSS: (AutoAdminLogon) Enable Automatic Logon (not recommended)' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (AutoAdminLogon) Enable Automatic Logon (not recommended)' - -18.4.2 CIS - Ensure 'MSS: (DisableIPSourceRouting IPv6) IP source routing protection level (protects against packet spoofing)' is set to 'Enabled: Highest protection, source routing is completely disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (DisableIPSourceRouting IPv6) IP source routing protection level (protects against packet spoofing)' - -18.4.3 CIS - Ensure 'MSS: (DisableIPSourceRouting) IP source routing protection level (protects against packet spoofing)' is set to 'Enabled: Highest protection, source routing is completely disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (DisableIPSourceRouting) IP source routing protection level (protects against packet spoofing)' - -18.4.4 CIS - Ensure 'MSS: (DisableSavePassword) Prevent the dial-up password from being saved' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS:(DisableSavePassword) Prevent the dial-up password from being saved' - -18.4.5 CIS - Ensure 'MSS: (EnableICMPRedirect) Allow ICMP redirects to override OSPF generated routes' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (EnableICMPRedirect) Allow ICMP redirects to override OSPF generated routes' - -18.4.6 CIS - Ensure 'MSS: (KeepAliveTime) How often keep-alive packets are sent in milliseconds' is set to 'Enabled: 300,000 or 5 minutes' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (KeepAliveTime) How often keep-alive packets are sent in milliseconds' - -18.4.7 CIS - Ensure 'MSS: (NoNameReleaseOnDemand) Allow the computer to ignore NetBIOS name release requests except from WINS servers' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (NoNameReleaseOnDemand) Allow the computer to ignore NetBIOS name release requests except from WINS servers' - -18.4.8 CIS - Ensure 'MSS: (PerformRouterDiscovery) Allow IRDP to detect and configure Default Gateway addresses (could lead to DoS)' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (PerformRouterDiscovery) Allow IRDP to detect and configure Default Gateway addresses (could lead to DoS)' - -18.4.9 CIS - Ensure 'MSS: (SafeDllSearchMode) Enable Safe DLL search mode (recommended)' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (SafeDllSearchMode) Enable Safe DLL search mode (recommended)' - -18.4.10 CIS - Ensure 'MSS: (ScreenSaverGracePeriod) The time in seconds before the screen saver grace period expires (0 recommended)' is set to 'Enabled: 5 or fewer seconds' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (ScreenSaverGracePeriod) The time in seconds before the screen saver grace period expires (0 recommended)' - -18.4.11 CIS - Ensure 'MSS: (TcpMaxDataRetransmissions IPv6) How many times unacknowledged data is retransmitted' is set to 'Enabled: 3' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS:(TcpMaxDataRetransmissions IPv6) How many times unacknowledged data is retransmitted' - -18.4.12 CIS - Ensure 'MSS: (TcpMaxDataRetransmissions) How many times unacknowledged data is retransmitted' is set to 'Enabled: 3' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS:(TcpMaxDataRetransmissions) How many times unacknowledged data is retransmitted' - -18.4.13 CIS - Ensure 'MSS: (WarningLevel) Percentage threshold for the security event log at which the system will generate a warning' is set to 'Enabled: 90% or less' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (WarningLevel) Percentage threshold for the security event log at which the system will generate a warning' - -18.8.21.2 CIS - Ensure 'Configure registry policy processing: Do not apply during periodic background processing' is set to 'Enabled: FALSE' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Group Policy\Configure registry policy processing' - -18.8.22.1.1 CIS - Ensure 'Turn off access to the Store' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off access to the Store' - -18.8.22.1.2 CIS - Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off downloading of print drivers over HTTP' - -18.8.22.1.3 CIS - Ensure 'Turn off handwriting personalization data sharing' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off handwriting personalization data sharing' - -18.8.22.1.4 CIS - Ensure 'Turn off handwriting recognition error reporting' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off handwriting recognition error reporting' - -18.8.22.1.5 CIS - Ensure 'Turn off Internet Connection Wizard if URL connection is referring to Microsoft.com' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Internet Connection Wizard if URL connection is referring to Microsoft.com' - -18.8.22.1.6 CIS - Ensure 'Turn off Internet download for Web publishing and online ordering wizards' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Internet download for Web publishing and online ordering wizards' - -18.8.22.1.7 CIS - Ensure 'Turn off printing over HTTP' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off printing over HTTP' - -18.8.22.1.8 CIS - Ensure 'Turn off Registration if URL connection is referring to Microsoft.com' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Registration if URL connection is referring to Microsoft.com' - -18.8.22.1.9 CIS - Ensure 'Turn off Search Companion content file updates' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Search Companion content file updates' - -18.8.22.1.10 CIS - Ensure 'Turn off the "Order Prints" picture task' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off the "Order Prints" picture task' - -18.8.22.1.11 CIS - Ensure 'Turn off the "Publish to Web" task for files and folders' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off the "Publish to Web" task for files and folders' - -18.8.22.1.12 CIS - Ensure 'Turn off the Windows Messenger Customer Experience Improvement Program' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off the Windows Messenger Customer Experience Improvement Program' - -18.8.22.1.13 CIS - Ensure 'Turn off Windows Customer Experience Improvement Program' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Windows Customer Experience Improvement Program' - -18.8.22.1.14 CIS - Ensure 'Turn off Windows Error Reporting' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Windows Error Reporting' - -18.8.25.1 CIS - Ensure 'Support device authentication using certificate' is set to 'Enabled: Automatic' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Kerberos\Support device authentication using certificate' - -18.8.26.1 CIS - Ensure 'Enumeration policy for external devices incompatible with Kernel DMA Protection' is set to 'Enabled: Block All' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Kernel DMA Protection\Enumeration policy for external devices incompatible with Kernel DMA Protection' - -18.8.27.1 CIS - Ensure 'Disallow copying of user input methods to the system account for sign-in' is set to 'Enabled' (Automated) -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Locale Services\Disallow copying of user input methods to the system account for sign-in' -``` - ## Performance testing In August 2023, we completed scale testing on 10k Windows hosts and 70k macOS hosts. Ultimately, we validated both server and host performance at that scale. diff --git a/docs/Using Fleet/Fleet-UI.md b/docs/Using Fleet/Fleet-UI.md index 0d4f2c1a39ae..da80b0342d60 100644 --- a/docs/Using Fleet/Fleet-UI.md +++ b/docs/Using Fleet/Fleet-UI.md @@ -52,7 +52,7 @@ Fleet allows you to schedule queries to run at a set frequency. Scheduled querie The default log destination, **filesystem**, is good to start. With this set, data is sent to the `/var/log/osquery/osqueryd.snapshots.log` file on each host’s filesystem. To see which log destinations are available in Fleet, head to the [log destinations page](https://fleetdm.com/docs/using-fleet/log-destinations). -By default, queries that run on a schedule will only target platforms compatible with that query. This behavior can be overridden by setting the platforms in the "advanced options" when saving a query. +By default, queries that run on a schedule will only target platforms compatible with that query. This behavior can be overridden by setting the platforms in "advanced options" when saving a query. **How to schedule queries:** @@ -94,4 +94,4 @@ See "[Agent configuration](https://fleetdm.com/docs/configuration/agent-configur - \ No newline at end of file + diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index 48c1a2152600..90774c86e1a7 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -75,7 +75,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | View Apple mobile device management (MDM) certificate information | | | | ✅ | | | View Apple business manager (BM) information | | | | ✅ | | | Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | | ✅ | | -| View disk encryption key for macOS hosts | ✅ | ✅ | ✅ | ✅ | | +| View disk encryption key for macOS and Windows hosts | ✅ | ✅ | ✅ | ✅ | | | Create edit and delete configuration profiles for macOS hosts | | | ✅ | ✅ | ✅ | | Execute MDM commands on macOS and Windows hosts*** | | | ✅ | ✅ | | | View results of MDM commands executed on macOS and Windows hosts*** | ✅ | ✅ | ✅ | ✅ | | diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index afd304a6c8f9..37cf53721821 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -890,12 +891,7 @@ func (svc *Service) getOrCreatePreassignTeam(ctx context.Context, groups []strin } payload.MDM = &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{ - // teams created by the match endpoint have disk encryption - // enabled by default. - // TODO: maybe make this configurable? - EnableDiskEncryption: true, - }, + EnableDiskEncryption: optjson.SetBool(true), MacOSSetup: &fleet.MacOSSetup{ MacOSSetupAssistant: ac.MDM.MacOSSetup.MacOSSetupAssistant, // NOTE: BootstrapPackage is currently ignored by svc.ModifyTeam and gets set @@ -968,3 +964,51 @@ func teamNameFromPreassignGroups(groups []string) string { return strings.Join(groups, " - ") } + +func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*fleet.MDMDiskEncryptionSummary, error) { + // TODO: Consider adding a new generic OSSetting type or Windows-specific type for authz checks + // like this. + if err := svc.authz.Authorize(ctx, fleet.MDMAppleConfigProfile{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + var macOS fleet.MDMAppleFileVaultSummary + if m, err := svc.ds.GetMDMAppleFileVaultSummary(ctx, teamID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting filevault summary") + } else if m != nil { + macOS = *m + } + + var windows fleet.MDMWindowsBitLockerSummary + if w, err := svc.ds.GetMDMWindowsBitLockerSummary(ctx, teamID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting bitlocker summary") + } else if w != nil { + windows = *w + } + + return &fleet.MDMDiskEncryptionSummary{ + Verified: fleet.MDMPlatformsCounts{ + MacOS: macOS.Verified, + Windows: windows.Verified, + }, + Verifying: fleet.MDMPlatformsCounts{ + MacOS: macOS.Verifying, + Windows: windows.Verifying, + }, + ActionRequired: fleet.MDMPlatformsCounts{ + MacOS: macOS.ActionRequired, + Windows: windows.ActionRequired, + }, + Enforcing: fleet.MDMPlatformsCounts{ + MacOS: macOS.Enforcing, + Windows: windows.Enforcing, + }, + Failed: fleet.MDMPlatformsCounts{ + MacOS: macOS.Failed, + Windows: windows.Failed, + }, + RemovingEnforcement: fleet.MDMPlatformsCounts{ + MacOS: macOS.RemovingEnforcement, + Windows: windows.RemovingEnforcement, + }, + }, nil +} diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 33a777323f1b..529408de47b0 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -150,13 +150,13 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } } - if payload.MDM.MacOSSettings != nil { - if !appCfg.MDM.EnabledAndConfigured && payload.MDM.MacOSSettings.EnableDiskEncryption { + if payload.MDM.EnableDiskEncryption.Valid { + macOSDiskEncryptionUpdated = team.Config.MDM.EnableDiskEncryption != payload.MDM.EnableDiskEncryption.Value + if macOSDiskEncryptionUpdated && !appCfg.MDM.EnabledAndConfigured { return nil, fleet.NewInvalidArgumentError("macos_settings.enable_disk_encryption", `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } - macOSDiskEncryptionUpdated = team.Config.MDM.MacOSSettings.EnableDiskEncryption != payload.MDM.MacOSSettings.EnableDiskEncryption - team.Config.MDM.MacOSSettings.EnableDiskEncryption = payload.MDM.MacOSSettings.EnableDiskEncryption + team.Config.MDM.EnableDiskEncryption = payload.MDM.EnableDiskEncryption.Value } if payload.MDM.MacOSSetup != nil { @@ -225,7 +225,7 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } if macOSDiskEncryptionUpdated { var act fleet.ActivityDetails - if team.Config.MDM.MacOSSettings.EnableDiskEncryption { + if team.Config.MDM.EnableDiskEncryption { act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name} if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil { return nil, ctxerr.Wrap(ctx, err, "enable team filevault and escrow") @@ -802,6 +802,17 @@ func (svc *Service) createTeamFromSpec( `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)) } } + enableDiskEncryption := spec.MDM.EnableDiskEncryption.Value + if !spec.MDM.EnableDiskEncryption.Valid { + if de := macOSSettings.DeprecatedEnableDiskEncryption; de != nil { + enableDiskEncryption = *de + } + } + + if enableDiskEncryption && !defaults.MDM.AtLeastOnePlatformEnabledAndConfigured() { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", + `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) + } if dryRun { return &fleet.Team{Name: spec.Name}, nil @@ -813,9 +824,10 @@ func (svc *Service) createTeamFromSpec( AgentOptions: agentOptions, Features: features, MDM: fleet.TeamMDM{ - MacOSUpdates: spec.MDM.MacOSUpdates, - MacOSSettings: macOSSettings, - MacOSSetup: macOSSetup, + EnableDiskEncryption: enableDiskEncryption, + MacOSUpdates: spec.MDM.MacOSUpdates, + MacOSSettings: macOSSettings, + MacOSSetup: macOSSetup, }, }, Secrets: secrets, @@ -824,7 +836,7 @@ func (svc *Service) createTeamFromSpec( return nil, err } - if macOSSettings.EnableDiskEncryption { + if enableDiskEncryption && defaults.MDM.EnabledAndConfigured { if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &tm.ID); err != nil { return nil, ctxerr.Wrap(ctx, err, "enable team filevault and escrow") } @@ -871,11 +883,23 @@ func (svc *Service) editTeamFromSpec( team.Config.MDM.MacOSUpdates = spec.MDM.MacOSUpdates } - oldMacOSDiskEncryption := team.Config.MDM.MacOSSettings.EnableDiskEncryption + oldMacOSDiskEncryption := team.Config.MDM.EnableDiskEncryption if err := svc.applyTeamMacOSSettings(ctx, spec, &team.Config.MDM.MacOSSettings); err != nil { return err } - newMacOSDiskEncryption := team.Config.MDM.MacOSSettings.EnableDiskEncryption + + // 1. if the spec has the new setting, use that + // 2. else if the spec has the deprecated setting, use that + // 3. otherwise, leave the setting untouched + if spec.MDM.EnableDiskEncryption.Valid { + team.Config.MDM.EnableDiskEncryption = spec.MDM.EnableDiskEncryption.Value + } else if de := team.Config.MDM.MacOSSettings.DeprecatedEnableDiskEncryption; de != nil { + team.Config.MDM.EnableDiskEncryption = *de + } + if team.Config.MDM.EnableDiskEncryption && !appCfg.MDM.AtLeastOnePlatformEnabledAndConfigured() { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", + `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) + } oldMacOSSetup := team.Config.MDM.MacOSSetup if spec.MDM.MacOSSetup.MacOSSetupAssistant.Set || spec.MDM.MacOSSetup.BootstrapPackage.Set { @@ -925,9 +949,9 @@ func (svc *Service) editTeamFromSpec( return err } } - if oldMacOSDiskEncryption != newMacOSDiskEncryption { + if appCfg.MDM.EnabledAndConfigured && oldMacOSDiskEncryption != team.Config.MDM.EnableDiskEncryption { var act fleet.ActivityDetails - if team.Config.MDM.MacOSSettings.EnableDiskEncryption { + if team.Config.MDM.EnableDiskEncryption { act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name} if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil { return ctxerr.Wrap(ctx, err, "enable team filevault and escrow") @@ -982,7 +1006,7 @@ func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.Team } if (setFields["custom_settings"] && len(applyUpon.CustomSettings) > 0) || - (setFields["enable_disk_encryption"] && applyUpon.EnableDiskEncryption) { + (setFields["enable_disk_encryption"] && *applyUpon.DeprecatedEnableDiskEncryption) { field := "custom_settings" if !setFields["custom_settings"] { field = "enable_disk_encryption" @@ -1016,8 +1040,8 @@ func unmarshalWithGlobalDefaults(b *json.RawMessage) (fleet.Features, error) { func (svc *Service) updateTeamMDMAppleSettings(ctx context.Context, tm *fleet.Team, payload fleet.MDMAppleSettingsPayload) error { var didUpdate, didUpdateMacOSDiskEncryption bool if payload.EnableDiskEncryption != nil { - if tm.Config.MDM.MacOSSettings.EnableDiskEncryption != *payload.EnableDiskEncryption { - tm.Config.MDM.MacOSSettings.EnableDiskEncryption = *payload.EnableDiskEncryption + if tm.Config.MDM.EnableDiskEncryption != *payload.EnableDiskEncryption { + tm.Config.MDM.EnableDiskEncryption = *payload.EnableDiskEncryption didUpdate = true didUpdateMacOSDiskEncryption = true } @@ -1029,7 +1053,7 @@ func (svc *Service) updateTeamMDMAppleSettings(ctx context.Context, tm *fleet.Te } if didUpdateMacOSDiskEncryption { var act fleet.ActivityDetails - if tm.Config.MDM.MacOSSettings.EnableDiskEncryption { + if tm.Config.MDM.EnableDiskEncryption { act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &tm.ID, TeamName: &tm.Name} if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &tm.ID); err != nil { return ctxerr.Wrap(ctx, err, "enable team filevault and escrow") diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index c51d2895d506..7b5131a2da52 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -12,6 +12,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { live_query_disabled: false, enable_analytics: true, deferred_save_host: false, + query_reports_disabled: false, }, smtp_settings: { enable_smtp: false, @@ -125,6 +126,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { }, fleet_desktop: { transparency_url: "https://fleetdm.com/transparency" }, mdm: { + enable_disk_encryption: false, windows_enabled_and_configured: true, apple_bm_default_team: "Apples", apple_bm_enabled_and_configured: true, diff --git a/frontend/__mocks__/hostMock.ts b/frontend/__mocks__/hostMock.ts index c2e0993649e2..6834d0c7037a 100644 --- a/frontend/__mocks__/hostMock.ts +++ b/frontend/__mocks__/hostMock.ts @@ -1,7 +1,7 @@ import { IHost } from "interfaces/host"; -import { IHostMacMdmProfile } from "interfaces/mdm"; +import { IHostMdmProfile } from "interfaces/mdm"; -const DEFAULT_HOST_PROFILE_MOCK: IHostMacMdmProfile = { +const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = { profile_id: 1, name: "Test Profile", operation_type: "install", @@ -10,8 +10,8 @@ const DEFAULT_HOST_PROFILE_MOCK: IHostMacMdmProfile = { }; export const createMockHostMacMdmProfile = ( - overrides?: Partial -): IHostMacMdmProfile => { + overrides?: Partial +): IHostMdmProfile => { return { ...DEFAULT_HOST_PROFILE_MOCK, ...overrides }; }; @@ -53,6 +53,11 @@ const DEFAULT_HOST_MOCK: IHost = { enrollment_status: "Off", server_url: "https://www.example.com/1", profiles: [], + os_settings: { + disk_encryption: { + status: null, + }, + }, macos_settings: { disk_encryption: null, action_required: null, diff --git a/frontend/__mocks__/mdmMock.ts b/frontend/__mocks__/mdmMock.ts index c0584bf66aaf..5ffebcdbc30d 100644 --- a/frontend/__mocks__/mdmMock.ts +++ b/frontend/__mocks__/mdmMock.ts @@ -36,6 +36,11 @@ const DEFAULT_HOST_MDM_DATA: IHostMdmData = { name: "MDM Solution", id: 1, profiles: [], + os_settings: { + disk_encryption: { + status: "verified", + }, + }, macos_settings: { disk_encryption: null, action_required: null, diff --git a/frontend/__mocks__/queryMock.ts b/frontend/__mocks__/queryMock.ts index df90eae93ab0..4d44c53347b1 100644 --- a/frontend/__mocks__/queryMock.ts +++ b/frontend/__mocks__/queryMock.ts @@ -12,6 +12,7 @@ const DEFAULT_QUERY_MOCK: ISchedulableQuery = { author_name: "Test User", author_email: "test@example.com", observer_can_run: false, + discard_data: false, interval: 300, packs: [], team_id: null, diff --git a/frontend/__mocks__/queryReportMock.ts b/frontend/__mocks__/queryReportMock.ts new file mode 100644 index 000000000000..eb538473d974 --- /dev/null +++ b/frontend/__mocks__/queryReportMock.ts @@ -0,0 +1,331 @@ +import { IQueryReport } from "interfaces/query_report"; + +const DEFAULT_QUERY_REPORT_MOCK: IQueryReport = { + query_id: 31, + results: [ + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "Razer Viper", + vendor: "Razer", + model_id: "0078", + }, + }, + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "USB Keyboard", + vendor: "VIA Labs, Inc.", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Keyboard", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "YubiKey OTP+FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "PixArt", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo Traditional USB Keyboard", + vendor: "Lenovo", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Bose", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple, Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Logitech Webcam C925e", + model_id: "085b", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Ambient Light Sensor", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "DELL Laser Mouse", + model_id: "4d51", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "AppleUSBVHCIBCE Root Hub Simulation", + vendor: "Apple Inc.", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "QuickFire Rapid keyboard", + vendor: "CM Storm", + model_id: "0004", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "Lenovo", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "YubiKey FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 4, + host_name: "car", + last_fetched: "2023-01-14T12:40:30Z", + columns: { + model: "USB2.0 Hub", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "FaceTime HD Camera (Display)", + vendor: "Apple Inc.", + model_id: "1112", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Internal Keyboard / Trackpad", + model_id: "027e", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Thunderbolt Display", + vendor: "Apple Inc.", + model_id: "9227", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "AppleUSBXHCI Root Hub Simulation", + vendor: "Apple Inc.", + model_id: "8007", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple T2 Controller", + vendor: "Apple Inc.", + model_id: "8233", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "4-Port USB 2.0 Hub", + vendor: "Generic", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB 10_100_1000 LAN", + vendor: "Realtek", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Mouse", + vendor: "Razor", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Audio", + vendor: "Apple, Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + ], +}; + +const createMockQueryReport = ( + overrides?: Partial +): IQueryReport => { + return { ...DEFAULT_QUERY_REPORT_MOCK, ...overrides }; +}; + +export default createMockQueryReport; diff --git a/frontend/__mocks__/scheduleableQueryMock.ts b/frontend/__mocks__/scheduleableQueryMock.ts index edc82a61da12..3ac57bb49de7 100644 --- a/frontend/__mocks__/scheduleableQueryMock.ts +++ b/frontend/__mocks__/scheduleableQueryMock.ts @@ -20,6 +20,7 @@ const DEFAULT_SCHEDULABLE_QUERY_MOCK: ISchedulableQuery = { author_name: "Test User", author_email: "test@example.com", observer_can_run: false, + discard_data: false, packs: [], stats: { system_time_p50: 28.1053, diff --git a/frontend/components/EmptyTable/_styles.scss b/frontend/components/EmptyTable/_styles.scss index c24d1c954356..15517d8d8f1c 100644 --- a/frontend/components/EmptyTable/_styles.scss +++ b/frontend/components/EmptyTable/_styles.scss @@ -57,7 +57,6 @@ &__container { align-self: center; justify-content: center; - margin: 0; margin-bottom: 20px; min-height: 155px; max-width: none; diff --git a/frontend/components/InfoBanner/InfoBanner.tsx b/frontend/components/InfoBanner/InfoBanner.tsx index 1eb8d6d0214f..1e02b38b6e21 100644 --- a/frontend/components/InfoBanner/InfoBanner.tsx +++ b/frontend/components/InfoBanner/InfoBanner.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import Icon from "components/Icon"; import Button from "components/buttons/Button"; +import { IconNames } from "components/icons"; const baseClass = "info-banner"; @@ -10,29 +11,37 @@ export interface IInfoBannerProps { children?: React.ReactNode; className?: string; /** default light purple */ - color?: "yellow" | "grey"; + color?: "purple" | "purple-bold-border" | "yellow" | "grey"; + /** default 4px */ + borderRadius?: "large" | "xlarge"; pageLevel?: boolean; /** cta and link are mutually exclusive */ cta?: JSX.Element; /** closable and link are mutually exclusive */ closable?: boolean; link?: string; + icon?: IconNames; } const InfoBanner = ({ children, className, - color, + color = "purple", + borderRadius, pageLevel, cta, closable, link, + icon, }: IInfoBannerProps): JSX.Element => { const wrapperClasses = classNames( baseClass, + `${baseClass}__${color}`, { [`${baseClass}__${color}`]: !!color, + [`${baseClass}__border-radius-${borderRadius}`]: !!borderRadius, [`${baseClass}__page-banner`]: !!pageLevel, + [`${baseClass}__icon`]: !!icon, }, className ); @@ -42,6 +51,7 @@ const InfoBanner = ({ const content = ( <>
{children}
+ {(cta || closable) && (
{cta} diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss index 5902cba3dba1..81635a106270 100644 --- a/frontend/components/InfoBanner/_styles.scss +++ b/frontend/components/InfoBanner/_styles.scss @@ -5,11 +5,19 @@ padding: $pad-medium; border-radius: $border-radius; border: 1px solid $ui-vibrant-blue-50; - background-color: $ui-vibrant-blue-10; font-size: $x-small; font-weight: $regular; color: $core-fleet-black; + &__purple { + background-color: $ui-vibrant-blue-10; + } + + &__purple-bold-border { + background-color: $ui-vibrant-blue-10; + border-color: $core-vibrant-blue; + } + &__yellow { background-color: $ui-yellow-banner; border-color: $ui-yellow-banner-outline; @@ -26,6 +34,20 @@ width: auto; } + &__border-radius-large { + border-radius: $border-radius-large; + } + + &__border-radius-xlarge { + border-radius: $border-radius-xlarge; + } + + &__info { + display: flex; + flex-direction: column; + gap: $pad-small; + } + &__cta { display: flex; align-items: center; @@ -51,4 +73,8 @@ } } } + + p { + margin: 0; + } } diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index c65a3e0e603e..998e4e6d523d 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -12,7 +12,7 @@ import { ISelectLabel, ISelectTeam, ISelectTargetsEntity, - ISelectedTargets, + ISelectedTargetsForApi, } from "interfaces/target"; import { ITeam } from "interfaces/team"; @@ -48,7 +48,9 @@ interface ISelectTargetsProps { targetedTeams: ITeam[]; goToQueryEditor: () => void; goToRunQuery: () => void; - setSelectedTargets: React.Dispatch>; + setSelectedTargets: // TODO: Refactor policy targets to streamline selectedTargets/selectedTargetsByType + | React.Dispatch> // Used for policies page level useState hook + | ((value: ITarget[]) => void); // Used for queries app level QueryContext setTargetedHosts: React.Dispatch>; setTargetedLabels: React.Dispatch>; setTargetedTeams: React.Dispatch>; @@ -65,7 +67,7 @@ interface ITargetsQueryKey { scope: string; query_id?: number | null; query?: string | null; - selected?: ISelectedTargets | null; + selected?: ISelectedTargetsForApi | null; } const DEBOUNCE_DELAY = 500; @@ -379,12 +381,22 @@ const SelectTargets = ({ } const { targets_count: total, targets_online: online } = counts; - const onlinePercentage = total > 0 ? Math.round((online / total) * 100) : 0; + const onlinePercentage = () => { + if (total === 0) { + return 0; + } + // If at least 1 host is online, displays <1% instead of 0% + const roundPercentage = + Math.round((online / total) * 100) === 0 + ? "<1" + : Math.round((online / total) * 100) === 0; + return roundPercentage; + }; return ( <> {total} host{total > 1 ? `s` : ``} targeted  ( - {onlinePercentage} + {onlinePercentage()} %  have recently checked
into Fleet.`} diff --git a/frontend/components/LiveQuery/TargetsInput/_styles.scss b/frontend/components/LiveQuery/TargetsInput/_styles.scss index e2f14ce6e152..137525dd04a8 100644 --- a/frontend/components/LiveQuery/TargetsInput/_styles.scss +++ b/frontend/components/LiveQuery/TargetsInput/_styles.scss @@ -79,4 +79,7 @@ overflow: auto; } } + .input-icon-field__icon { + top: 34px; // Override styling to include label header + } } diff --git a/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx b/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx index e510748a7a35..95ae8d5659e6 100644 --- a/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx +++ b/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx @@ -23,7 +23,10 @@ interface IStatusIndicatorWithIconProps { tooltipText: string | JSX.Element; position?: "top" | "bottom"; }; + layout?: "horizontal" | "vertical"; className?: string; + /** Classname to add to the value text */ + valueClassName?: string; } const statusIconNameMapping: Record = { @@ -38,13 +41,18 @@ const StatusIndicatorWithIcon = ({ status, value, tooltip, + layout = "horizontal", className, + valueClassName, }: IStatusIndicatorWithIconProps) => { const classNames = classnames(baseClass, className); const id = `status-${uniqueId()}`; + const valueClasses = classnames(`${baseClass}__value`, valueClassName, { + [`${baseClass}__value-vertical`]: layout === "vertical", + }); const valueContent = ( - + {value} diff --git a/frontend/components/StatusIndicatorWithIcon/_styles.scss b/frontend/components/StatusIndicatorWithIcon/_styles.scss index 6dea8de69713..12d60c0f2d63 100644 --- a/frontend/components/StatusIndicatorWithIcon/_styles.scss +++ b/frontend/components/StatusIndicatorWithIcon/_styles.scss @@ -1,4 +1,5 @@ .status-indicator-with-icon { + // default layout is horizontal &__value { display: inline-flex; align-items: center; @@ -8,4 +9,10 @@ margin-right: $pad-xsmall; } } + + // overrides for different layout + &__value-vertical { + flex-direction: column; + gap: $pad-xsmall; + } } diff --git a/frontend/components/TableContainer/DataTable/DefaultColumnFilter/DefaultColumnFilter.tsx b/frontend/components/TableContainer/DataTable/DefaultColumnFilter/DefaultColumnFilter.tsx index facc7e72dc92..df3b609b781e 100644 --- a/frontend/components/TableContainer/DataTable/DefaultColumnFilter/DefaultColumnFilter.tsx +++ b/frontend/components/TableContainer/DataTable/DefaultColumnFilter/DefaultColumnFilter.tsx @@ -8,6 +8,11 @@ const DefaultColumnFilter = ({ }: FilterProps): JSX.Element => { const { setFilter } = column; + // Remove last_fetched filter per design as it is confusing to filter by a non-displayed date-string + if (column.id === "last_fetched") { + return <>; + } + return (
{ return ( { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CollectingResults; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index cd1885064313..36ac93a84278 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -4,6 +4,7 @@ import ArrowInternalLink from "./ArrowInternalLink"; import CalendarCheck from "./CalendarCheck"; import Check from "./Check"; import Chevron from "./Chevron"; +import CollectingResults from "./CollectingResults"; import Columns from "./Columns"; import CriticalPolicy from "./CriticalPolicy"; import Disable from "./Disable"; @@ -84,6 +85,7 @@ export const ICON_MAP = { "calendar-check": CalendarCheck, chevron: Chevron, check: Check, + "collecting-results": CollectingResults, columns: Columns, "critical-policy": CriticalPolicy, disable: Disable, diff --git a/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx b/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx index ed2bf4953a25..e807a83b6aa4 100644 --- a/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx +++ b/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx @@ -1,19 +1,18 @@ import React from "react"; -import PhoneHome from "../../../../../assets/images/phone-home.svg"; +import EmptyTable from "components/EmptyTable/EmptyTable"; const baseClass = "awaiting-results"; const AwaitingResults = () => { return ( -
- awaiting results - Phoning home... -

- There are currently no results to your query. Please wait while we talk - to more hosts. -

-
+ ); }; diff --git a/frontend/components/queries/queryResults/AwaitingResults/_styles.scss b/frontend/components/queries/queryResults/AwaitingResults/_styles.scss index 12463a384435..ba2b2dad9b23 100644 --- a/frontend/components/queries/queryResults/AwaitingResults/_styles.scss +++ b/frontend/components/queries/queryResults/AwaitingResults/_styles.scss @@ -5,19 +5,4 @@ flex-direction: column; align-items: center; text-align: center; - - img { - margin-bottom: $pad-medium; - } - - &__title { - font-size: $small; - font-weight: $bold; - margin-bottom: $pad-small; - } - - &__description { - font-size: $x-small; - margin: 0; - } } diff --git a/frontend/context/query.tsx b/frontend/context/query.tsx index 992e152cadee..73bd7a297e69 100644 --- a/frontend/context/query.tsx +++ b/frontend/context/query.tsx @@ -6,6 +6,12 @@ import { DEFAULT_QUERY } from "utilities/constants"; import { DEFAULT_OSQUERY_TABLE, IOsQueryTable } from "interfaces/osquery_table"; import { SelectedPlatformString } from "interfaces/platform"; import { QueryLoggingOption } from "interfaces/schedulable_query"; +import { + DEFAULT_TARGETS, + DEFAULT_TARGETS_BY_TYPE, + ISelectedTargetsByType, + ITarget, +} from "interfaces/target"; type Props = { children: ReactNode; @@ -22,6 +28,9 @@ type InitialStateType = { lastEditedQueryPlatforms: SelectedPlatformString; lastEditedQueryMinOsqueryVersion: string; lastEditedQueryLoggingType: QueryLoggingOption; + lastEditedQueryDiscardData: boolean; + selectedQueryTargets: ITarget[]; // Mimicks old selectedQueryTargets still used for policies for SelectTargets.tsx and running a live query + selectedQueryTargetsByType: ISelectedTargetsByType; // New format by type for cleaner app wide state setLastEditedQueryId: (value: number | null) => void; setLastEditedQueryName: (value: string) => void; setLastEditedQueryDescription: (value: string) => void; @@ -31,7 +40,10 @@ type InitialStateType = { setLastEditedQueryPlatforms: (value: SelectedPlatformString) => void; setLastEditedQueryMinOsqueryVersion: (value: string) => void; setLastEditedQueryLoggingType: (value: string) => void; + setLastEditedQueryDiscardData: (value: boolean) => void; setSelectedOsqueryTable: (tableName: string) => void; + setSelectedQueryTargets: (value: ITarget[]) => void; + setSelectedQueryTargetsByType: (value: ISelectedTargetsByType) => void; }; export type IQueryContext = InitialStateType; @@ -48,6 +60,9 @@ const initialState = { lastEditedQueryPlatforms: DEFAULT_QUERY.platform, lastEditedQueryMinOsqueryVersion: DEFAULT_QUERY.min_osquery_version, lastEditedQueryLoggingType: DEFAULT_QUERY.logging, + lastEditedQueryDiscardData: DEFAULT_QUERY.discard_data, + selectedQueryTargets: DEFAULT_TARGETS, + selectedQueryTargetsByType: DEFAULT_TARGETS_BY_TYPE, setLastEditedQueryId: () => null, setLastEditedQueryName: () => null, setLastEditedQueryDescription: () => null, @@ -57,12 +72,17 @@ const initialState = { setLastEditedQueryPlatforms: () => null, setLastEditedQueryMinOsqueryVersion: () => null, setLastEditedQueryLoggingType: () => null, + setLastEditedQueryDiscardData: () => null, setSelectedOsqueryTable: () => null, + setSelectedQueryTargets: () => null, + setSelectedQueryTargetsByType: () => null, }; const actions = { SET_SELECTED_OSQUERY_TABLE: "SET_SELECTED_OSQUERY_TABLE", SET_LAST_EDITED_QUERY_INFO: "SET_LAST_EDITED_QUERY_INFO", + SET_SELECTED_QUERY_TARGETS: "SET_SELECTED_QUERY_TARGETS", + SET_SELECTED_QUERY_TARGETS_BY_TYPE: "SET_SELECTED_QUERY_TARGETS_BY_TYPE", } as const; const reducer = (state: InitialStateType, action: any) => { @@ -113,6 +133,26 @@ const reducer = (state: InitialStateType, action: any) => { typeof action.lastEditedQueryLoggingType === "undefined" ? state.lastEditedQueryLoggingType : action.lastEditedQueryLoggingType, + lastEditedQueryDiscardData: + typeof action.lastEditedQueryDiscardData === "undefined" + ? state.lastEditedQueryDiscardData + : action.lastEditedQueryDiscardData, + }; + case actions.SET_SELECTED_QUERY_TARGETS: + return { + ...state, + selectedQueryTargets: + typeof action.selectedQueryTargets === "undefined" + ? state.selectedQueryTargets + : action.selectedQueryTargets, + }; + case actions.SET_SELECTED_QUERY_TARGETS_BY_TYPE: + return { + ...state, + selectedQueryTargetsByType: + typeof action.selectedQueryTargetsByType === "undefined" + ? state.selectedQueryTargetsByType + : action.selectedQueryTargetsByType, }; default: return state; @@ -135,6 +175,9 @@ const QueryProvider = ({ children }: Props) => { lastEditedQueryPlatforms: state.lastEditedQueryPlatforms, lastEditedQueryMinOsqueryVersion: state.lastEditedQueryMinOsqueryVersion, lastEditedQueryLoggingType: state.lastEditedQueryLoggingType, + lastEditedQueryDiscardData: state.lastEditedQueryDiscardData, + selectedQueryTargets: state.selectedQueryTargets, + selectedQueryTargetsByType: state.selectedQueryTargetsByType, setLastEditedQueryId: (lastEditedQueryId: number | null) => { dispatch({ type: actions.SET_LAST_EDITED_QUERY_INFO, @@ -193,6 +236,26 @@ const QueryProvider = ({ children }: Props) => { lastEditedQueryLoggingType, }); }, + setLastEditedQueryDiscardData: (lastEditedQueryDiscardData: boolean) => { + dispatch({ + type: actions.SET_LAST_EDITED_QUERY_INFO, + lastEditedQueryDiscardData, + }); + }, + setSelectedQueryTargets: (selectedQueryTargets: ITarget[]) => { + dispatch({ + type: actions.SET_SELECTED_QUERY_TARGETS, + selectedQueryTargets, + }); + }, + setSelectedQueryTargetsByType: ( + selectedQueryTargetsByType: ISelectedTargetsByType + ) => { + dispatch({ + type: actions.SET_SELECTED_QUERY_TARGETS_BY_TYPE, + selectedQueryTargetsByType, + }); + }, setSelectedOsqueryTable: (tableName: string) => { dispatch({ type: actions.SET_SELECTED_OSQUERY_TABLE, tableName }); }, diff --git a/frontend/hooks/useQueryTargets.ts b/frontend/hooks/useQueryTargets.ts index 081b473afe4f..d594ab856670 100644 --- a/frontend/hooks/useQueryTargets.ts +++ b/frontend/hooks/useQueryTargets.ts @@ -4,7 +4,7 @@ import { filter, uniqueId } from "lodash"; import { IHost } from "interfaces/host"; import { ILabel } from "interfaces/label"; import { ITeam } from "interfaces/team"; -import { ISelectedTargets } from "interfaces/target"; +import { ISelectedTargetsForApi } from "interfaces/target"; import targetsAPI from "services/entities/targets"; export interface ITargetsLabels { @@ -25,7 +25,7 @@ export interface ITargetsQueryKey { scope: string; query: string; queryId: number | null; - selected: ISelectedTargets; + selected: ISelectedTargetsForApi; includeLabels: boolean; } diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index c604629c0c79..cf15ccca6463 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -1,95 +1,11 @@ /* Config interface is a flattened version of the fleet/config API response */ - import { IWebhookHostStatus, IWebhookFailingPolicies, IWebhookSoftwareVulnerabilities, } from "interfaces/webhook"; -import PropTypes from "prop-types"; import { IIntegrations } from "./integration"; -export default PropTypes.shape({ - org_name: PropTypes.string, - org_logo_url: PropTypes.string, - contact_url: PropTypes.string, - server_url: PropTypes.string, - live_query_disabled: PropTypes.bool, - enable_analytics: PropTypes.bool, - enable_smtp: PropTypes.bool, - configured: PropTypes.bool, - sender_address: PropTypes.string, - server: PropTypes.string, - port: PropTypes.number, - authentication_type: PropTypes.string, - user_name: PropTypes.string, - password: PropTypes.string, - enable_ssl_tls: PropTypes.bool, - authentication_method: PropTypes.string, - domain: PropTypes.string, - verify_sll_certs: PropTypes.bool, - enable_start_tls: PropTypes.bool, - entity_id: PropTypes.string, - idp_image_url: PropTypes.string, - metadata: PropTypes.string, - metadata_url: PropTypes.string, - idp_name: PropTypes.string, - enable_sso: PropTypes.bool, - enable_sso_idp_login: PropTypes.bool, - enable_jit_provisioning: PropTypes.bool, - host_expiry_enabled: PropTypes.bool, - host_expiry_window: PropTypes.number, - agent_options: PropTypes.string, - tier: PropTypes.string, - organization: PropTypes.string, - device_count: PropTypes.number, - expiration: PropTypes.string, - mdm: PropTypes.shape({ - enabled_and_configured: PropTypes.bool, - apple_bm_terms_expired: PropTypes.bool, - apple_bm_enabled_and_configured: PropTypes.bool, - windows_enabled_and_configured: PropTypes.bool, - macos_updates: PropTypes.shape({ - minimum_version: PropTypes.string, - deadline: PropTypes.string, - }), - }), - note: PropTypes.string, - // vulnerability_settings: PropTypes.any, TODO - enable_host_status_webhook: PropTypes.bool, - destination_url: PropTypes.string, - host_percentage: PropTypes.number, - days_count: PropTypes.number, - logging: PropTypes.shape({ - debug: PropTypes.bool, - json: PropTypes.bool, - result: PropTypes.shape({ - plugin: PropTypes.string, - config: PropTypes.shape({ - status_log_file: PropTypes.string, - result_log_file: PropTypes.string, - enable_log_rotation: PropTypes.bool, - enable_log_compression: PropTypes.bool, - }), - }), - status: PropTypes.shape({ - plugin: PropTypes.string, - config: PropTypes.shape({ - status_log_file: PropTypes.string, - result_log_file: PropTypes.string, - enable_log_rotation: PropTypes.bool, - enable_log_compression: PropTypes.bool, - }), - }), - }), - email: PropTypes.shape({ - backend: PropTypes.string, - config: PropTypes.shape({ - region: PropTypes.string, - source_arn: PropTypes.string, - }), - }), -}); - export interface ILicense { tier: string; device_count: number; @@ -113,6 +29,7 @@ export interface IMacOsMigrationSettings { } export interface IMdmConfig { + enable_disk_encryption: boolean; enabled_and_configured: boolean; apple_bm_default_team?: string; apple_bm_terms_expired: boolean; @@ -196,6 +113,7 @@ export interface IConfig { live_query_disabled: boolean; enable_analytics: boolean; deferred_save_host: boolean; + query_reports_disabled: boolean; }; smtp_settings: { enable_smtp: boolean; @@ -285,7 +203,10 @@ export interface IConfig { }; }; mdm: IMdmConfig; - mdm_enabled?: boolean; // TODO: remove when windows MDM is released. Only used for windows MDM dev currently. + /** This is the flag that determines if the windwos mdm feature flag is enabled. + TODO: WINDOWS FEATURE FLAG: remove when windows MDM is released. Only used for windows MDM dev currently. + */ + mdm_enabled?: boolean; } export interface IWebhookSettings { diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 1927351b7dd8..ebfde247f461 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -8,9 +8,10 @@ import hostQueryResult from "./campaign"; import queryStatsInterface, { IQueryStats } from "./query_stats"; import { ILicense, IDeviceGlobalConfig } from "./config"; import { - IHostMacMdmProfile, + IHostMdmProfile, MdmEnrollmentStatus, BootstrapPackageStatus, + DiskEncryptionStatus, } from "./mdm"; export default PropTypes.shape({ @@ -90,18 +91,16 @@ export interface IMunkiData { version: string; } -type MacDiskEncryptionState = - | "applied" - | "action_required" - | "enforcing" - | "failed" - | "removing_enforcement" - | null; - type MacDiskEncryptionActionRequired = "log_out" | "rotate_key" | null; +export interface IOSSettings { + disk_encryption: { + status: DiskEncryptionStatus | null; + }; +} + interface IMdmMacOsSettings { - disk_encryption: MacDiskEncryptionState | null; + disk_encryption: DiskEncryptionStatus | null; action_required: MacDiskEncryptionActionRequired | null; } @@ -117,7 +116,8 @@ export interface IHostMdmData { name?: string; server_url: string | null; id?: number; - profiles: IHostMacMdmProfile[] | null; + profiles: IHostMdmProfile[] | null; + os_settings?: IOSSettings; macos_settings?: IMdmMacOsSettings; macos_setup?: IMdmMacOsSetup; } @@ -210,7 +210,7 @@ export interface IHost { osquery_version: string; os_version: string; build: string; - platform_like: string; + platform_like: string; // TODO: replace with more specific union type code_name: string; uptime: number; memory: number; diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 25f0f0d7ec8b..1120e1ab625c 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -22,8 +22,6 @@ export const MDM_ENROLLMENT_STATUS = { export type MdmEnrollmentStatus = keyof typeof MDM_ENROLLMENT_STATUS; -export type ProfileSummaryResponse = Record; - export interface IMdmStatusCardData { status: MdmEnrollmentStatus; hosts: number; @@ -74,16 +72,15 @@ export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed"; export type MacMdmProfileOperationType = "remove" | "install"; -export interface IHostMacMdmProfile { +export interface IHostMdmProfile { profile_id: number; name: string; - // identifier?: string; // TODO: add when API is updated to return this - operation_type: MacMdmProfileOperationType; + operation_type: MacMdmProfileOperationType | null; status: MdmProfileStatus; detail: string; } -export type FileVaultProfileStatus = +export type DiskEncryptionStatus = | "verified" | "verifying" | "action_required" @@ -91,9 +88,18 @@ export type FileVaultProfileStatus = | "failed" | "removing_enforcement"; -// // TODO: update when list profiles API returns identifier -// export const FLEET_FILEVAULT_PROFILE_IDENTIFIER = -// "com.fleetdm.fleet.mdm.filevault"; +/** Currently windows disk enxryption status will only be one of these four +values. In the future we may add more. */ +export type IWindowsDiskEncryptionStatus = Extract< + DiskEncryptionStatus, + "verified" | "verifying" | "enforcing" | "failed" +>; + +export const isWindowsDiskEncryptionStatus = ( + status: DiskEncryptionStatus +): status is IWindowsDiskEncryptionStatus => { + return !["action_required", "removing_enforcement"].includes(status); +}; export const FLEET_FILEVAULT_PROFILE_DISPLAY_NAME = "Disk encryption"; diff --git a/frontend/interfaces/query.ts b/frontend/interfaces/query.ts index d6a948cd25b7..cad78f3745a3 100644 --- a/frontend/interfaces/query.ts +++ b/frontend/interfaces/query.ts @@ -3,7 +3,7 @@ import { IPack } from "./pack"; import { ISchedulableQuery } from "./schedulable_query"; import { IScheduledQueryStats } from "./scheduled_query_stats"; -export interface IQueryFormData { +export interface IEditQueryFormData { description?: string | number | boolean | undefined; name?: string | number | boolean | undefined; query?: string | number | boolean | undefined; @@ -35,7 +35,7 @@ export interface IQuery { stats?: IScheduledQueryStats; } -export interface IQueryFormFields { +export interface IEditQueryFormFields { description: IFormField; name: IFormField; query: IFormField; diff --git a/frontend/interfaces/query_report.ts b/frontend/interfaces/query_report.ts new file mode 100644 index 000000000000..9310fcc4e812 --- /dev/null +++ b/frontend/interfaces/query_report.ts @@ -0,0 +1,12 @@ +export interface IQueryReportResultRow { + host_id: number; + host_name: string; + last_fetched: string; + columns: any; +} + +// Query report +export interface IQueryReport { + query_id: number; + results: IQueryReportResultRow[]; +} diff --git a/frontend/interfaces/schedulable_query.ts b/frontend/interfaces/schedulable_query.ts index 9d86c98b8527..1a57fb8a7246 100644 --- a/frontend/interfaces/schedulable_query.ts +++ b/frontend/interfaces/schedulable_query.ts @@ -21,6 +21,7 @@ export interface ISchedulableQuery { author_name: string; author_email: string; observer_can_run: boolean; + discard_data: boolean; packs: IPack[]; stats: ISchedulableQueryStats; } @@ -62,6 +63,7 @@ export interface ICreateQueryRequestBody { query: string; description?: string; observer_can_run?: boolean; + discard_data?: boolean; team_id?: number; // global query if ommitted interval?: number; // default 0 means never run platform?: SelectedPlatformString; // Might more accurately be called `platforms_to_query` – comma-sepparated string of platforms to query, default all platforms if ommitted @@ -81,6 +83,7 @@ export interface IModifyQueryRequestBody query?: string; description?: string; observer_can_run?: boolean; + discard_data?: boolean; frequency?: number; platform?: SelectedPlatformString; min_osquery_version?: string; @@ -108,11 +111,12 @@ export interface IDeleteQueriesResponse { deleted: number; // number of queries deleted } -export interface IQueryFormFields { +export interface IEditQueryFormFields { name: IFormField; description: IFormField; query: IFormField; observer_can_run: IFormField; + discard_data: IFormField; frequency: IFormField; platforms: IFormField; min_osquery_version: IFormField; diff --git a/frontend/interfaces/target.ts b/frontend/interfaces/target.ts index 75267192123f..06f464cf51b3 100644 --- a/frontend/interfaces/target.ts +++ b/frontend/interfaces/target.ts @@ -38,14 +38,29 @@ export interface ISelectTeam extends ITeam { export type ISelectTargetsEntity = ISelectHost | ISelectLabel | ISelectTeam; -export interface ISelectedTargets { +export interface ISelectedTargetsForApi { hosts: number[]; labels: number[]; teams: number[]; } +export interface ISelectedTargetsByType { + hosts: IHost[]; + labels: ILabel[]; + teams: ITeam[]; +} + export interface IPackTargets { host_ids: (number | string)[]; label_ids: (number | string)[]; team_ids: (number | string)[]; } + +// TODO: Also use for testing +export const DEFAULT_TARGETS: ITarget[] = []; + +export const DEFAULT_TARGETS_BY_TYPE: ISelectedTargetsByType = { + hosts: [], + labels: [], + teams: [], +}; diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 3c06c11c36c8..4487dba48d86 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -44,6 +44,7 @@ export interface ITeam extends ITeamSummary { secrets?: IEnrollSecret[]; role?: UserRole; // role value is included when the team is in the context of a user mdm?: { + enable_disk_encryption: boolean; macos_updates: { minimum_version: string; deadline: string; diff --git a/frontend/pages/LoginSuccessfulPage/_styles.scss b/frontend/pages/LoginSuccessfulPage/_styles.scss index 07e1429fa755..b3b962d3c9b9 100644 --- a/frontend/pages/LoginSuccessfulPage/_styles.scss +++ b/frontend/pages/LoginSuccessfulPage/_styles.scss @@ -3,7 +3,7 @@ margin-top: 20px; padding: $pad-xxlarge; text-align: center; - border-radius: 10px; + border-radius: $border-radius-xlarge; z-index: 0; align-self: center; transform: translateY(80px); diff --git a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/AggregateMacSettingsIndicators.tsx b/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/AggregateMacSettingsIndicators.tsx deleted file mode 100644 index 7c4b4906011b..000000000000 --- a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/AggregateMacSettingsIndicators.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; - -import paths from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; -import { MdmProfileStatus, ProfileSummaryResponse } from "interfaces/mdm"; -import MacSettingsIndicator from "pages/hosts/details/MacSettingsIndicator"; - -import { IconNames } from "components/icons"; -import Spinner from "components/Spinner"; - -const baseClass = "aggregate-mac-settings-indicators"; - -interface IAggregateDisplayOption { - value: MdmProfileStatus; - text: string; - iconName: IconNames; - tooltipText: string; -} - -const AGGREGATE_STATUS_DISPLAY_OPTIONS: IAggregateDisplayOption[] = [ - { - value: "verified", - text: "Verified", - iconName: "success", - tooltipText: - "These hosts installed all configuration profiles. Fleet verified with osquery.", - }, - { - value: "verifying", - text: "Verifying", - iconName: "success-partial", - tooltipText: - "These hosts acknowledged all MDM commands to install configuration profiles. " + - "Fleet is verifying the profiles are installed with osquery.", - }, - { - value: "pending", - text: "Pending", - iconName: "pending-partial", - tooltipText: - "These hosts will receive MDM commands to install configuration profiles when the hosts come online.", - }, - { - value: "failed", - text: "Failed", - iconName: "error", - tooltipText: - "These hosts failed to install configuration profiles. Click on a host to view error(s).", - }, -]; - -interface AggregateMacSettingsIndicatorsProps { - isLoading: boolean; - teamId: number; - aggregateProfileStatusData?: ProfileSummaryResponse; -} - -const AggregateMacSettingsIndicators = ({ - isLoading, - teamId, - aggregateProfileStatusData, -}: AggregateMacSettingsIndicatorsProps) => { - const indicators = AGGREGATE_STATUS_DISPLAY_OPTIONS.map((status) => { - if (!aggregateProfileStatusData) return null; - - const { value, text, iconName, tooltipText } = status; - const count = aggregateProfileStatusData[value]; - - return ( - - ); - }); - - if (isLoading) { - return ( -
- -
- ); - } - - return
{indicators}
; -}; - -export default AggregateMacSettingsIndicators; diff --git a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/index.ts b/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/index.ts deleted file mode 100644 index 1032f0ac5014..000000000000 --- a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./AggregateMacSettingsIndicators"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx index dddbffc04805..9eb28475ba7b 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx @@ -4,12 +4,11 @@ import { useQuery } from "react-query"; import { AppContext } from "context/app"; import SideNav from "pages/admin/components/SideNav"; -import { ProfileSummaryResponse } from "interfaces/mdm"; import { API_NO_TEAM_ID, APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import mdmAPI from "services/entities/mdm"; import OS_SETTINGS_NAV_ITEMS from "./OSSettingsNavItems"; -import AggregateMacSettingsIndicators from "./AggregateMacSettingsIndicators"; +import ProfileStatusAggregate from "./ProfileStatusAggregate"; import TurnOnMdmMessage from "../components/TurnOnMdmMessage"; const baseClass = "os-settings"; @@ -40,9 +39,10 @@ const OSSettings = ({ data: aggregateProfileStatusData, refetch: refetchAggregateProfileStatus, isLoading: isLoadingAggregateProfileStatus, - } = useQuery( + } = useQuery( ["aggregateProfileStatuses", teamId], - () => mdmAPI.getAggregateProfileStatuses(teamId), + () => + mdmAPI.getAggregateProfileStatuses(teamId, config?.mdm_enabled ?? false), { refetchOnWindowFocus: false, retry: false, @@ -50,7 +50,10 @@ const OSSettings = ({ ); // MDM is not on so show messaging for user to enable it. - if (!config?.mdm.enabled_and_configured) { + if ( + !config?.mdm.enabled_and_configured && + !config?.mdm.windows_enabled_and_configured + ) { return ; } @@ -67,7 +70,7 @@ const OSSettings = ({

Remotely enforce settings on macOS hosts assigned to this team.

- { + const generateFilterHostsByStatusLink = () => { + return `${paths.MANAGE_HOSTS}?${buildQueryStringFromParams({ + team_id: teamId, + macos_settings: statusValue, + })}`; + }; + + return ( + + ); +}; + +interface ProfileStatusAggregateProps { + isLoading: boolean; + teamId: number; + aggregateProfileStatusData?: ProfileStatusSummaryResponse; +} + +const ProfileStatusAggregate = ({ + isLoading, + teamId, + aggregateProfileStatusData, +}: ProfileStatusAggregateProps) => { + if (!aggregateProfileStatusData) return null; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const indicators = AGGREGATE_STATUS_DISPLAY_OPTIONS.map((status) => { + const { value, text, iconName, tooltipText } = status; + const count = aggregateProfileStatusData[value]; + + return ( + + ); + }); + + return
{indicators}
; +}; + +export default ProfileStatusAggregate; diff --git a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregateOptions.ts b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregateOptions.ts new file mode 100644 index 000000000000..8dbe94abf8fe --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregateOptions.ts @@ -0,0 +1,43 @@ +import { MdmProfileStatus } from "interfaces/mdm"; +import { IndicatorStatus } from "components/StatusIndicatorWithIcon/StatusIndicatorWithIcon"; + +interface IAggregateDisplayOption { + value: MdmProfileStatus; + text: string; + iconName: IndicatorStatus; + tooltipText: string; +} + +const AGGREGATE_STATUS_DISPLAY_OPTIONS: IAggregateDisplayOption[] = [ + { + value: "verified", + text: "Verified", + iconName: "success", + tooltipText: + "These hosts applied all OS settings. Fleet verified with osquery.", + }, + { + value: "verifying", + text: "Verifying", + iconName: "successPartial", + tooltipText: + "These hosts acknowledged all MDM commands to apply OS settings. " + + "Fleet is verifying the OS settings are applied with osquery.", + }, + { + value: "pending", + text: "Pending", + iconName: "pendingPartial", + tooltipText: + "These hosts will receive MDM command to apply OS settings when the host come online.", + }, + { + value: "failed", + text: "Failed", + iconName: "error", + tooltipText: + "These host failed to apply the latest OS settings. Click on a host to view error(s).", + }, +]; + +export default AGGREGATE_STATUS_DISPLAY_OPTIONS; diff --git a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss similarity index 73% rename from frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/_styles.scss rename to frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss index 76659544939a..89744cf6cc71 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss @@ -1,16 +1,16 @@ -.aggregate-mac-settings-indicators { +.profile-status-aggregate { display: flex; height: 94px; border-top: 1px solid #e2e4ea; border-bottom: 1px solid #e2e4ea; border-left: 1px solid #e2e4ea; - border-radius: 6px; + border-radius: $border-radius-large; &__loading-spinner { margin: auto; } - .aggregate-mac-settings-indicator { + &__profile-status-count { flex-grow: 1; display: flex; @@ -29,13 +29,17 @@ font-weight: $regular; } - .settings-indicator { + .profile-status-indicator { flex-direction: column; } } - .aggregate-mac-settings-indicator:last-child { + &__profile-status-count:last-child { border-top-right-radius: 6px; border-bottom-right-radius: 6px; } + + &__status-indicator-value { + font-weight: $bold; + } } diff --git a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/index.ts b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/index.ts new file mode 100644 index 000000000000..a29bd5e10d5f --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/index.ts @@ -0,0 +1 @@ +export { default } from "./ProfileStatusAggregate"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx index 6edd4f35f5f2..c8cd1d2bc37b 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx @@ -31,7 +31,7 @@ const DiskEncryption = ({ const defaultShowDiskEncryption = currentTeamId ? false - : config?.mdm.macos_settings.enable_disk_encryption ?? false; + : config?.mdm.enable_disk_encryption ?? false; const [isLoadingTeam, setIsLoadingTeam] = useState(true); @@ -67,8 +67,7 @@ const DiskEncryption = ({ enabled: currentTeamId !== 0, select: (res) => res.team, onSuccess: (res) => { - const enableDiskEncryption = - res.mdm?.macos_settings.enable_disk_encryption ?? false; + const enableDiskEncryption = res.mdm?.enable_disk_encryption ?? false; setDiskEncryptionEnabled(enableDiskEncryption); setShowAggregate(enableDiskEncryption); setIsLoadingTeam(false); @@ -100,6 +99,19 @@ const DiskEncryption = ({ setIsLoadingTeam(false); } + const createDescriptionText = () => { + // table is showing disk encryption status. + if (showAggregate) { + return "If turned on, hosts' disk encryption keys will be stored in Fleet. "; + } + + const isWindowsFeatureFlagEnabled = config?.mdm_enabled ?? false; + const dynamicText = isWindowsFeatureFlagEnabled + ? " and “BitLocker” on Windows" + : ""; + return `Also known as “FileVault” on macOS${dynamicText}. If turned on, hosts' disk encryption keys will be stored in Fleet. `; + }; + return (

Disk encryption

@@ -124,8 +136,7 @@ const DiskEncryption = ({ On

- Apple calls this “FileVault.” If turned on, hosts' disk - encryption keys will be stored in Fleet.{" "} + {createDescriptionText()} { + const { config } = useContext(AppContext); + const { data: diskEncryptionStatusData, error: diskEncryptionStatusError, - } = useQuery( + } = useQuery( ["disk-encryption-summary", currentTeamId], - () => mdmAPI.getDiskEncryptionAggregate(currentTeamId), + () => mdmAPI.getDiskEncryptionSummary(currentTeamId), { refetchOnWindowFocus: false, retry: false, } ); - const tableHeaders = generateTableHeaders(); - - const tableData = generateTableData(diskEncryptionStatusData, currentTeamId); + // TODO: WINDOWS FEATURE FLAG: remove this when windows feature flag is removed. + // this is used to conditianlly show "View all hosts" link in table cells. + const windowsFeatureFlagEnabled = config?.mdm_enabled ?? false; + const tableHeaders = generateTableHeaders(windowsFeatureFlagEnabled); + const tableData = generateTableData( + windowsFeatureFlagEnabled, + diskEncryptionStatusData, + currentTeamId + ); if (diskEncryptionStatusError) { return ; @@ -53,8 +59,7 @@ const DiskEncryptionTable = ({ currentTeamId }: IDiskEncryptionTableProps) => { isLoading={false} showMarkAllPages={false} isAllPagesSelected={false} - defaultSortHeader={DEFAULT_SORT_HEADER} - defaultSortDirection={DEFAULT_SORT_DIRECTION} + manualSortBy disableTableHeader disablePagination disableCount diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx index 068459d69cfb..9a5b7baf5dbe 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx @@ -1,7 +1,11 @@ import React from "react"; -import { FileVaultProfileStatus } from "interfaces/mdm"; -import { IFileVaultSummaryResponse } from "services/entities/mdm"; +import { DiskEncryptionStatus } from "interfaces/mdm"; +import { + IDiskEncryptionStatusAggregate, + IDiskEncryptionSummaryResponse, +} from "services/entities/mdm"; +import { DISK_ENCRYPTION_QUERY_PARAM_NAME } from "services/entities/hosts"; import TextCell from "components/TableContainer/DataTable/TextCell"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; @@ -12,7 +16,7 @@ import { IndicatorStatus } from "components/StatusIndicatorWithIcon/StatusIndica interface IStatusCellValue { displayName: string; statusName: IndicatorStatus; - value: FileVaultProfileStatus; + value: DiskEncryptionStatus; tooltip?: string | JSX.Element; } @@ -28,6 +32,7 @@ interface ICellProps { }; row: { original: { + includeWindows: boolean; status: IStatusCellValue; teamId: number; }; @@ -72,15 +77,53 @@ const defaultTableHeaders: IDataColumn[] = [ }, }, { - title: "Hosts", + title: "macOS hosts", Header: (cellProps: IHeaderProps) => ( ), - accessor: "hosts", + disableSortBy: true, + accessor: "macosHosts", + Cell: ({ + cell: { value: aggregateCount }, + row: { original }, + }: ICellProps) => { + return ( +

+ <>{val}} /> + {/* TODO: WINDOWS FEATURE FLAG: remove this conditional when windows mdm + is released. the view all UI will show in the windows column when we + release the feature. */} + {!original.includeWindows && ( + + )} +
+ ); + }, + }, +]; + +const windowsTableHeader: IDataColumn[] = [ + { + title: "Windows hosts", + Header: (cellProps: IHeaderProps) => ( + + ), + disableSortBy: true, + accessor: "windowsHosts", Cell: ({ cell: { value: aggregateCount }, row: { original }, @@ -91,7 +134,7 @@ const defaultTableHeaders: IDataColumn[] = [ @@ -101,15 +144,17 @@ const defaultTableHeaders: IDataColumn[] = [ }, ]; -type StatusNames = keyof IFileVaultSummaryResponse; - -type StatusEntry = [StatusNames, number]; - -export const generateTableHeaders = (): IDataColumn[] => { +// TODO: WINDOWS FEATURE FLAG: return all headers when windows feature flag is removed. +export const generateTableHeaders = ( + includeWindows: boolean +): IDataColumn[] => { + return includeWindows + ? [...defaultTableHeaders, ...windowsTableHeader] + : defaultTableHeaders; return defaultTableHeaders; }; -const STATUS_CELL_VALUES: Record = { +const STATUS_CELL_VALUES: Record = { verified: { displayName: "Verified", statusName: "success", @@ -122,8 +167,8 @@ const STATUS_CELL_VALUES: Record = { statusName: "successPartial", value: "verifying", tooltip: - "These hosts acknowledged the MDM command to install disk encryption profile. " + - "Fleet is verifying with osquery and retrieving the disk encryption key. This may take up to one hour.", + "These hosts acknowledged the MDM command to turn on disk encryption. Fleet is verifying with " + + "osquery and retrieving the disk encryption key. This may take up to one hour.", }, action_required: { displayName: "Action required (pending)", @@ -141,7 +186,7 @@ const STATUS_CELL_VALUES: Record = { statusName: "pendingPartial", value: "enforcing", tooltip: - "These hosts will receive the MDM command to install the disk encryption profile when the hosts come online.", + "These hosts will receive the MDM command to turn on disk encryption when the hosts come online.", }, failed: { displayName: "Failed", @@ -153,21 +198,41 @@ const STATUS_CELL_VALUES: Record = { statusName: "pendingPartial", value: "removing_enforcement", tooltip: - "These hosts will receive the MDM command to remove the disk encryption profile when the hosts come online.", + "These hosts will receive the MDM command to turn off disk encryption when the hosts come online.", }, }; +type StatusEntry = [DiskEncryptionStatus, IDiskEncryptionStatusAggregate]; + +// Order of the status column. We want the order to always be the same. +const STATUS_ORDER = [ + "verified", + "verifying", + "failed", + "action_required", + "enforcing", + "removing_enforcement", +] as const; + export const generateTableData = ( - data?: IFileVaultSummaryResponse, + // TODO: WINDOWS FEATURE FLAG: remove includeWindows when windows feature flag is removed. + // This is used to conditionally show "View all hosts" link in table cells. + includeWindows: boolean, + data?: IDiskEncryptionSummaryResponse, currentTeamId?: number ) => { if (!data) return []; - const entries = Object.entries(data) as StatusEntry[]; - return entries.map(([status, numHosts]) => ({ - // eslint-disable-next-line object-shorthand + const rowFromStatusEntry = ( + status: DiskEncryptionStatus, + statusAggregate: IDiskEncryptionStatusAggregate + ) => ({ + includeWindows, status: STATUS_CELL_VALUES[status], - hosts: numHosts, + macosHosts: statusAggregate.macos, + windowsHosts: statusAggregate.windows, teamId: currentTeamId, - })); + }); + + return STATUS_ORDER.map((status) => rowFromStatusEntry(status, data[status])); }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss index ee3e1c025ea8..c2e35efe6b8b 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss @@ -1,7 +1,4 @@ .disk-encryption-table { - padding: $pad-xxlarge; - border: 1px solid $ui-fleet-black-10; - border-radius: $border-radius; margin-bottom: $pad-xxlarge; .data-table-block .data-table tbody td .w250 { diff --git a/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx b/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx index 545887a8f5d2..ce052c31e180 100644 --- a/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx +++ b/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx @@ -30,7 +30,7 @@ const TurnOnMdmMessage = ({ router }: ITurnOnMdmMessageProps) => { return ( diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx index 61da4d5b8df4..62f6d4d4b5c7 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx @@ -18,7 +18,7 @@ const Advanced = ({ handleSubmit, isUpdatingSettings, }: IAppConfigFormProps): JSX.Element => { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ domain: appConfig.smtp_settings.domain || "", verifySSLCerts: appConfig.smtp_settings.verify_ssl_certs || false, enableStartTLS: appConfig.smtp_settings.enable_start_tls, @@ -26,6 +26,8 @@ const Advanced = ({ appConfig.host_expiry_settings.host_expiry_enabled || false, hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0, disableLiveQuery: appConfig.server_settings.live_query_disabled || false, + disableQueryReports: + appConfig.server_settings.query_reports_disabled || false, }); const { @@ -35,6 +37,7 @@ const Advanced = ({ enableHostExpiry, hostExpiryWindow, disableLiveQuery, + disableQueryReports, } = formData; const [formErrors, setFormErrors] = useState({}); @@ -69,6 +72,7 @@ const Advanced = ({ server_url: appConfig.server_settings.server_url || "", live_query_disabled: disableLiveQuery, enable_analytics: appConfig.server_settings.enable_analytics, + query_reports_disabled: disableQueryReports, }, smtp_settings: { enable_smtp: appConfig.smtp_settings.enable_smtp || false, @@ -172,6 +176,24 @@ const Advanced = ({ > Disable live queries + Disabling query reports will decrease database usage,
\ + but will prevent you from accessing query results in
\ + Fleet and will delete existing reports. This can also be
\ + disabled on a per-query basis by enabling "Discard
\ + data". (Default: Off)

' + } + > + Disable query reports +
diff --git a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx index 2fbaac795a75..b4102d321e83 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx @@ -1,6 +1,7 @@ import React from "react"; import Icon from "components/Icon"; +import { DISK_ENCRYPTION_QUERY_PARAM_NAME } from "services/entities/hosts"; export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [ "query", @@ -17,7 +18,7 @@ export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [ "os_version", "munki_issue_id", "low_disk_space", - "macos_settings_disk_encryption", + DISK_ENCRYPTION_QUERY_PARAM_NAME, "bootstrap_package", ] as const; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index ab6a3b4debcd..6eedf30627c1 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -18,6 +18,7 @@ import labelsAPI, { ILabelsResponse } from "services/entities/labels"; import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams"; import globalPoliciesAPI from "services/entities/global_policies"; import hostsAPI, { + DISK_ENCRYPTION_QUERY_PARAM_NAME, ILoadHostsQueryKey, ILoadHostsResponse, ISortOption, @@ -49,7 +50,7 @@ import { IOperatingSystemVersion } from "interfaces/operating_system"; import { IPolicy, IStoredPolicyResponse } from "interfaces/policy"; import { ITeam } from "interfaces/team"; import { IEmptyTableProps } from "interfaces/empty_table"; -import { FileVaultProfileStatus, BootstrapPackageStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus, BootstrapPackageStatus } from "interfaces/mdm"; import sortUtils from "utilities/sort"; import { @@ -232,8 +233,8 @@ const ManageHostsPage = ({ ? parseInt(queryParams.low_disk_space, 10) : undefined; const missingHosts = queryParams?.status === "missing"; - const diskEncryptionStatus: FileVaultProfileStatus | undefined = - queryParams?.macos_settings_disk_encryption; + const diskEncryptionStatus: DiskEncryptionStatus | undefined = + queryParams?.[DISK_ENCRYPTION_QUERY_PARAM_NAME]; const bootstrapPackageStatus: BootstrapPackageStatus | undefined = queryParams?.bootstrap_package; @@ -558,7 +559,7 @@ const ManageHostsPage = ({ }; const handleChangeDiskEncryptionStatusFilter = ( - newStatus: FileVaultProfileStatus + newStatus: DiskEncryptionStatus ) => { handleResetPageIndex(); @@ -569,7 +570,7 @@ const ManageHostsPage = ({ routeParams, queryParams: { ...queryParams, - macos_settings_disk_encryption: newStatus, + [DISK_ENCRYPTION_QUERY_PARAM_NAME]: newStatus, page: 0, // resets page index }, }) @@ -768,7 +769,7 @@ const ManageHostsPage = ({ newQueryParams.os_version = osVersion; } else if (diskEncryptionStatus && isPremiumTier) { // Premium feature only - newQueryParams.macos_settings_disk_encryption = diskEncryptionStatus; + newQueryParams[DISK_ENCRYPTION_QUERY_PARAM_NAME] = diskEncryptionStatus; } else if (bootstrapPackageStatus && isPremiumTier) { newQueryParams.bootstrap_package = bootstrapPackageStatus; } diff --git a/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss index 38a15997083d..d4cb6bed093e 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss @@ -33,7 +33,7 @@ line-height: 1.5; background-color: $ui-light-grey; border: solid 1px $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; font-size: $small; padding: 9.5px 12px 9.5px 36px; color: $core-fleet-blue; @@ -70,7 +70,7 @@ color: $core-vibrant-red; border: 1px solid $core-vibrant-red; box-sizing: border-box; - border-radius: 4px; + border-radius: $border-radius; &:focus { border-color: $ui-error; diff --git a/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx b/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx index 2405fd6d151b..4e49123919f1 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx @@ -4,7 +4,7 @@ import { IDropdownOption } from "interfaces/dropdownOption"; // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; -import { FileVaultProfileStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus } from "interfaces/mdm"; const baseClass = "disk-encryption-status-filter"; @@ -42,8 +42,8 @@ const DISK_ENCRYPTION_STATUS_OPTIONS: IDropdownOption[] = [ ]; interface IDiskEncryptionStatusFilterProps { - diskEncryptionStatus: FileVaultProfileStatus; - onChange: (value: FileVaultProfileStatus) => void; + diskEncryptionStatus: DiskEncryptionStatus; + onChange: (value: DiskEncryptionStatus) => void; } const DiskEncryptionStatusFilter = ({ diff --git a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss index a7badf53a1f1..8f4a9c76a377 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss @@ -4,7 +4,7 @@ align-items: center; padding: 6px 12px; border: 1px solid $ui-fleet-black-25; - border-radius: 4px; + border-radius: $border-radius; box-shadow: none; color: $core-fleet-black; font-size: $xx-small; diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx index 0e64c52c744f..1ff7287cb3c8 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx @@ -7,7 +7,7 @@ import { IOperatingSystemVersion, } from "interfaces/operating_system"; import { - FileVaultProfileStatus, + DiskEncryptionStatus, BootstrapPackageStatus, IMdmSolution, MDM_ENROLLMENT_STATUS, @@ -15,7 +15,10 @@ import { import { IMunkiIssuesAggregate } from "interfaces/macadmins"; import { ISoftware } from "interfaces/software"; import { IPolicy } from "interfaces/policy"; -import { MacSettingsStatusQueryParam } from "services/entities/hosts"; +import { + DISK_ENCRYPTION_QUERY_PARAM_NAME, + MacSettingsStatusQueryParam, +} from "services/entities/hosts"; import { PLATFORM_LABEL_DISPLAY_NAMES, @@ -60,7 +63,7 @@ interface IHostsFilterBlockProps { osVersions?: IOperatingSystemVersion[]; softwareDetails: ISoftware | null; mdmSolutionDetails: IMdmSolution | null; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; }; selectedLabel?: ILabel; @@ -68,9 +71,7 @@ interface IHostsFilterBlockProps { handleClearRouteParam: () => void; handleClearFilter: (omitParams: string[]) => void; onChangePoliciesFilter: (response: PolicyResponse) => void; - onChangeDiskEncryptionStatusFilter: ( - response: FileVaultProfileStatus - ) => void; + onChangeDiskEncryptionStatusFilter: (response: DiskEncryptionStatus) => void; onChangeBootstrapPackageStatusFilter: ( response: BootstrapPackageStatus ) => void; @@ -376,8 +377,8 @@ const HostsFilterBlock = ({ onChange={onChangeDiskEncryptionStatusFilter} /> handleClearFilter(["macos_settings_disk_encryption"])} + label="OS settings: Disk encryption" + onClear={() => handleClearFilter([DISK_ENCRYPTION_QUERY_PARAM_NAME])} /> ); diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 285c0f8908ad..d5550026d577 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -417,6 +417,7 @@ const DeviceUserPage = ({ showRefetchSpinner={showRefetchSpinner} onRefetchHost={onRefetchHost} renderActionButtons={renderActionButtons} + osSettings={host?.mdm.os_settings} deviceUser /> @@ -489,6 +490,7 @@ const DeviceUserPage = ({ )} {showMacSettingsModal && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 0d1a0b6b46d3..48212d6a10f0 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -13,6 +13,7 @@ import queryAPI from "services/entities/queries"; import teamAPI, { ILoadTeamsResponse } from "services/entities/teams"; import { AppContext } from "context/app"; import { PolicyContext } from "context/policy"; +import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; import { IHost, @@ -26,6 +27,7 @@ import { ILabel } from "interfaces/label"; import { IHostPolicy } from "interfaces/policy"; import { IQueryStats } from "interfaces/query_stats"; import { ISoftware } from "interfaces/software"; +import { DEFAULT_TARGETS_BY_TYPE } from "interfaces/target"; import { ITeam } from "interfaces/team"; import { IListQueriesResponse, @@ -39,8 +41,13 @@ import MainContent from "components/MainContent"; import InfoBanner from "components/InfoBanner"; import BackLink from "components/BackLink"; -import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers"; +import { + normalizeEmptyValues, + wrapFleetHelper, + TAGGED_TEMPLATES, +} from "utilities/helpers"; import permissions from "utilities/permissions"; +import { DEFAULT_QUERY } from "utilities/constants"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -65,6 +72,7 @@ import HostActionDropdown from "./HostActionsDropdown/HostActionsDropdown"; import MacSettingsModal from "../MacSettingsModal"; import BootstrapPackageModal from "./modals/BootstrapPackageModal"; import SelectQueryModal from "./modals/SelectQueryModal"; +import { isSupportedPlatform } from "./modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal"; const baseClass = "host-details"; @@ -99,12 +107,6 @@ interface IHostDetailsSubNavItem { pathname: string; } -const TAGGED_TEMPLATES = { - queryByHostRoute: (hostId: number | undefined | null) => { - return `${hostId ? `?host_ids=${hostId}` : ""}`; - }, -}; - const HostDetailsPage = ({ route, router, @@ -135,6 +137,7 @@ const HostDetailsPage = ({ setLastEditedQueryCritical, setPolicyTeamId, } = useContext(PolicyContext); + const { setSelectedQueryTargetsByType } = useContext(QueryContext); const { renderFlash } = useContext(NotificationContext); const handlePageError = useErrorHandler(); @@ -521,12 +524,15 @@ const HostDetailsPage = ({ }; const onQueryHostCustom = () => { + setLastEditedQueryBody(DEFAULT_QUERY.query); + setSelectedQueryTargetsByType(DEFAULT_TARGETS_BY_TYPE); router.push( PATHS.NEW_QUERY() + TAGGED_TEMPLATES.queryByHostRoute(host?.id) ); }; const onQueryHostSaved = (selectedQuery: ISchedulableQuery) => { + setSelectedQueryTargetsByType(DEFAULT_TARGETS_BY_TYPE); router.push( PATHS.EDIT_QUERY(selectedQuery.id) + TAGGED_TEMPLATES.queryByHostRoute(host?.id) @@ -720,6 +726,7 @@ const HostDetailsPage = ({ showRefetchSpinner={showRefetchSpinner} onRefetchHost={onRefetchHost} renderActionButtons={renderActionButtons} + osSettings={host?.mdm.os_settings} /> @@ -852,12 +860,15 @@ const HostDetailsPage = ({ {showUnenrollMdmModal && !!host && ( )} - {showDiskEncryptionModal && host && ( - setShowDiskEncryptionModal(false)} - /> - )} + {showDiskEncryptionModal && + host && + isSupportedPlatform(host.platform) && ( + setShowDiskEncryptionModal(false)} + /> + )} {showBootstrapPackageModal && bootstrapPackageData.details && bootstrapPackageData.name && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx index 8acdc6622ebd..1046822673b2 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx @@ -9,15 +9,32 @@ import CustomLink from "components/CustomLink"; import Button from "components/buttons/Button"; import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenContent"; import DataError from "components/DataError"; +import { SupportedPlatform } from "interfaces/platform"; const baseClass = "disk-encryption-key-modal"; +// currently these are the only supported platforms for the disk encryption +// key modal. +export type ModalSupportedPlatform = Extract< + SupportedPlatform, + "darwin" | "windows" +>; + +// Checks to see if the platform is supported by the modal. +export const isSupportedPlatform = ( + platform: string +): platform is ModalSupportedPlatform => { + return ["darwin", "windows"].includes(platform); +}; + interface IDiskEncryptionKeyModal { + platform: ModalSupportedPlatform; hostId: number; onCancel: () => void; } const DiskEncryptionKeyModal = ({ + platform, hostId, onCancel, }: IDiskEncryptionKeyModal) => { @@ -33,6 +50,18 @@ const DiskEncryptionKeyModal = ({ select: (data) => data.encryption_key.key, }); + const isMacOS = platform === "darwin"; + const descriptionText = isMacOS + ? "The disk encryption key refers to the FileVault recovery key for macOS." + : "The disk encryption key refers to the BitLocker recovery key for Windows."; + + const recoveryText = isMacOS + ? "Use this key to log in to the host if you forgot the password." + : "Use this key to unlock the encrypted drive."; + const recoveryUrl = isMacOS + ? "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#reset-a-macos-hosts-password-using-the-disk-encryption-key" + : "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#unlock-a-windows-hosts-drive-using-the-disk-encryption-key"; + return ( {encryptionKeyError ? ( @@ -40,15 +69,12 @@ const DiskEncryptionKeyModal = ({ ) : ( <> +

{descriptionText}

- The disk encryption key refers to the FileVault recovery key for - macOS. -

-

- Use this key to log in to the host if you forgot the password.{" "} + {recoveryText}{" "}

diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss index 7f2f5f1ff1b7..c0b6053f3043 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss @@ -42,7 +42,7 @@ &__copy-message { background-color: $ui-light-grey; border: solid 1px #e2e4ea; - border-radius: 10px; + border-radius: $border-radius-xlarge; padding: 2px 6px; } } diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/index.ts b/frontend/pages/hosts/details/MacSettingsIndicator/index.ts deleted file mode 100644 index 47e97520648e..000000000000 --- a/frontend/pages/hosts/details/MacSettingsIndicator/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./MacSettingsIndicator"; diff --git a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx index a055bae71536..eab9e92bbbe0 100644 --- a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx +++ b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx @@ -7,20 +7,28 @@ import MacSettingsTable from "./MacSettingsTable"; import { generateTableData } from "./MacSettingsTable/MacSettingsTableConfig"; interface IMacSettingsModalProps { - hostMDMData?: Pick; + platform?: string; + hostMDMData?: IHostMdmData; onClose: () => void; } const baseClass = "mac-settings-modal"; -const MacSettingsModal = ({ hostMDMData, onClose }: IMacSettingsModalProps) => { - const memoizedTableData = useMemo(() => generateTableData(hostMDMData), [ - hostMDMData, - ]); +const MacSettingsModal = ({ + platform, + hostMDMData, + onClose, +}: IMacSettingsModalProps) => { + const memoizedTableData = useMemo( + () => generateTableData(hostMDMData, platform), + [hostMDMData, platform] + ); + + if (!platform) return null; return ( innerProps.isDiskEncryptionProfile - ? "The host will receive the MDM command to install the disk encryption profile when the " + - "host comes online." + ? "The hosts will receive the MDM command to turn on disk encryption " + + "when the hosts come online." : "The host will receive the MDM command to install the configuration profile when the " + "host comes online.", }, @@ -56,8 +59,8 @@ const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { iconName: "success", tooltip: (innerProps) => innerProps.isDiskEncryptionProfile - ? "The host turned disk encryption on and " + - "sent their key to Fleet. Fleet verified with osquery." + ? "The host turned disk encryption on and sent the key to Fleet. " + + "Fleet verified with osquery." : "The host installed the configuration profile. Fleet verified with osquery.", }, verifying: { @@ -65,8 +68,9 @@ const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { iconName: "success-partial", tooltip: (innerProps) => innerProps.isDiskEncryptionProfile - ? "The host acknowledged the MDM command to install disk encryption profile. Fleet is " + - "verifying with osquery and retrieving the disk encryption key. This may take up to one hour." + ? "The host acknowledged the MDM command to turn on disk encryption. " + + "Fleet is verifying with osquery and retrieving the disk encryption key. " + + "This may take up to one hour." : "The host acknowledged the MDM command to install the configuration profile. Fleet is " + "verifying with osquery.", }, @@ -98,9 +102,41 @@ const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { }, }; +type WindowsDiskEncryptionDisplayConfig = Omit< + OperationTypeOption, + "action_required" +>; + +const WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG: WindowsDiskEncryptionDisplayConfig = { + verified: { + statusText: "Verified", + iconName: "success", + tooltip: () => + "The host turned disk encryption on and sent the key to Fleet. Fleet verified with osquery.", + }, + verifying: { + statusText: "Verifying", + iconName: "success-partial", + tooltip: () => + "The host acknowledged the MDM command to turn on disk encryption. Fleet is verifying with osquery and retrieving " + + "the disk encryption key. This may take up to one hour.", + }, + pending: { + statusText: "Enforcing (pending)", + iconName: "pending-partial", + tooltip: () => + "The host will receive the MDM command to turn on disk encryption when the host comes online.", + }, + failed: { + statusText: "Failed", + iconName: "error", + tooltip: null, + }, +}; + interface IMacSettingStatusCellProps { status: MacSettingsTableStatusValue; - operationType: MacMdmProfileOperationType; + operationType: MacMdmProfileOperationType | null; profileName: string; } @@ -108,8 +144,18 @@ const MacSettingStatusCell = ({ status, operationType, profileName = "", -}: IMacSettingStatusCellProps): JSX.Element => { - const diplayOption = PROFILE_DISPLAY_CONFIG[operationType]?.[status]; +}: IMacSettingStatusCellProps) => { + let displayOption: ProfileDisplayOption = null; + + // windows hosts do not have an operation type at the moment and their display options are + // different than mac hosts. + if (!operationType && isMdmProfileStatus(status)) { + displayOption = WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG[status]; + } + + if (operationType) { + displayOption = PROFILE_DISPLAY_CONFIG[operationType]?.[status]; + } const isDeviceUser = window.location.pathname .toLowerCase() @@ -118,8 +164,8 @@ const MacSettingStatusCell = ({ const isDiskEncryptionProfile = profileName === FLEET_FILEVAULT_PROFILE_DISPLAY_NAME; - if (diplayOption) { - const { statusText, iconName, tooltip } = diplayOption; + if (displayOption) { + const { statusText, iconName, tooltip } = displayOption; const tooltipId = uniqueId(); return ( diff --git a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx index 39bd021222b4..7b2364af255c 100644 --- a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx +++ b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx @@ -5,20 +5,27 @@ import { IHostMdmData } from "interfaces/host"; import { FLEET_FILEVAULT_PROFILE_DISPLAY_NAME, // FLEET_FILEVAULT_PROFILE_IDENTIFIER, - IHostMacMdmProfile, + IHostMdmProfile, MdmProfileStatus, + isWindowsDiskEncryptionStatus, } from "interfaces/mdm"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; import TruncatedTextCell from "components/TableContainer/DataTable/TruncatedTextCell"; import MacSettingStatusCell from "./MacSettingStatusCell"; +import { generateWinDiskEncryptionProfile } from "../../helpers"; -export interface IMacSettingsTableRow - extends Omit { +export interface IMacSettingsTableRow extends Omit { status: MacSettingsTableStatusValue; } export type MacSettingsTableStatusValue = MdmProfileStatus | "action_required"; +export const isMdmProfileStatus = ( + status: string +): status is MdmProfileStatus => { + return status !== "action_required"; +}; + interface IHeaderProps { column: { title: string; @@ -92,20 +99,41 @@ const tableHeaders: IDataColumn[] = [ ]; export const generateTableData = ( - hostMDMData?: Pick + hostMDMData?: IHostMdmData, + platform?: string ) => { + if (!platform) return []; + let rows: IMacSettingsTableRow[] = []; if (!hostMDMData) { return rows; } + if ( + platform === "windows" && + hostMDMData.os_settings?.disk_encryption.status && + isWindowsDiskEncryptionStatus( + hostMDMData.os_settings.disk_encryption.status + ) + ) { + rows.push( + generateWinDiskEncryptionProfile( + hostMDMData.os_settings.disk_encryption.status + ) + ); + return rows; + } + const { profiles, macos_settings } = hostMDMData; + if (!profiles) { return rows; } - rows = profiles; - if (macos_settings?.disk_encryption === "action_required") { + if ( + platform === "darwin" && + macos_settings?.disk_encryption === "action_required" + ) { rows = profiles.map((p) => { // TODO: this is a brittle check for the filevault profile // it would be better to match on the identifier but it is not diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tests.tsx b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tests.tsx similarity index 87% rename from frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tests.tsx rename to frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tests.tsx index a71467903cec..73044dd21805 100644 --- a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tests.tsx +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tests.tsx @@ -1,12 +1,15 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; -import MacSettingsIndicator from "./MacSettingsIndicator"; +import ProfileStatusIndicator from "./ProfileStatusIndicator"; -describe("MacSettingsIndicator", () => { +describe("ProfileStatusIndicator component", () => { it("Renders the text and icon", () => { const indicatorText = "test text"; render( - + ); const renderedIndicatorText = screen.getByText(indicatorText); const renderedIcon = screen.getByTestId("success-icon"); @@ -19,7 +22,7 @@ describe("MacSettingsIndicator", () => { const indicatorText = "test text"; const tooltipText = "test tooltip text"; render( - { document.body.appendChild(newDiv); }; render( - { diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tsx b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tsx similarity index 92% rename from frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tsx rename to frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tsx index 473745ee3344..f3bbe6cd024c 100644 --- a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tsx +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tsx @@ -4,9 +4,9 @@ import { IconNames } from "components/icons"; import Icon from "components/Icon"; import Button from "components/buttons/Button"; -const baseClass = "settings-indicator"; +const baseClass = "profile-status-indicator"; -export interface IMacSettingsIndicator { +export interface IProfileStatusIndicatorProps { indicatorText: string; iconName: IconNames; onClick?: () => void; @@ -16,12 +16,12 @@ export interface IMacSettingsIndicator { }; } -const MacSettingsIndicator = ({ +const ProfileStatusIndicator = ({ indicatorText, iconName, onClick, tooltip, -}: IMacSettingsIndicator): JSX.Element => { +}: IProfileStatusIndicatorProps) => { const getIndicatorTextWrapped = () => { if (onClick && tooltip?.tooltipText) { return ( @@ -103,4 +103,4 @@ const MacSettingsIndicator = ({ ); }; -export default MacSettingsIndicator; +export default ProfileStatusIndicator; diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/_styles.scss b/frontend/pages/hosts/details/ProfileStatusIndicator/_styles.scss similarity index 88% rename from frontend/pages/hosts/details/MacSettingsIndicator/_styles.scss rename to frontend/pages/hosts/details/ProfileStatusIndicator/_styles.scss index fce8265c95eb..a7ed40ad9155 100644 --- a/frontend/pages/hosts/details/MacSettingsIndicator/_styles.scss +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/_styles.scss @@ -1,4 +1,4 @@ -.settings-indicator { +.profile-status-indicator { display: flex; gap: 4px; diff --git a/frontend/pages/hosts/details/ProfileStatusIndicator/index.ts b/frontend/pages/hosts/details/ProfileStatusIndicator/index.ts new file mode 100644 index 000000000000..99de4100ca68 --- /dev/null +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/index.ts @@ -0,0 +1 @@ +export { default } from "./ProfileStatusIndicator"; diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index f18194844c59..45be0eaba57b 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -1,7 +1,12 @@ import React from "react"; - import ReactTooltip from "react-tooltip"; -import { IHostMacMdmProfile, BootstrapPackageStatus } from "interfaces/mdm"; + +import { + IHostMdmProfile, + BootstrapPackageStatus, + isWindowsDiskEncryptionStatus, +} from "interfaces/mdm"; +import { IOSSettings } from "interfaces/host"; import getHostStatusTooltipText from "pages/hosts/helpers"; import TooltipWrapper from "components/TooltipWrapper"; @@ -9,6 +14,7 @@ import Button from "components/buttons/Button"; import Icon from "components/Icon/Icon"; import DiskSpaceGraph from "components/DiskSpaceGraph"; import HumanTimeDiffWithDateTip from "components/HumanTimeDiffWithDateTip"; +import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; import { getHostDiskEncryptionTooltipMessage, humanHostMemory, @@ -16,10 +22,11 @@ import { } from "utilities/helpers"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; import StatusIndicator from "components/StatusIndicator"; -import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; + import MacSettingsIndicator from "./MacSettingsIndicator"; import HostSummaryIndicator from "./HostSummaryIndicator"; import BootstrapPackageIndicator from "./BootstrapPackageIndicator/BootstrapPackageIndicator"; +import { generateWinDiskEncryptionProfile } from "../../helpers"; const baseClass = "host-summary"; @@ -38,7 +45,7 @@ interface IHostSummaryProps { toggleOSPolicyModal?: () => void; toggleMacSettingsModal?: () => void; toggleBootstrapPackageModal?: () => void; - hostMdmProfiles?: IHostMacMdmProfile[]; + hostMdmProfiles?: IHostMdmProfile[]; mdmName?: string; showRefetchSpinner: boolean; onRefetchHost: ( @@ -46,6 +53,7 @@ interface IHostSummaryProps { ) => void; renderActionButtons: () => JSX.Element | null; deviceUser?: boolean; + osSettings?: IOSSettings; } const HostSummary = ({ @@ -64,8 +72,9 @@ const HostSummary = ({ onRefetchHost, renderActionButtons, deviceUser, + osSettings, }: IHostSummaryProps): JSX.Element => { - const { status, id, platform } = titleData; + const { status, platform } = titleData; const renderRefetch = () => { const isOnline = titleData.status === "online"; @@ -179,6 +188,22 @@ const HostSummary = ({ }; const renderSummary = () => { + // for windows hosts we have to manually add a profile for disk encryption + // as this is not currently included in the `profiles` value from the API + // response for windows hosts. + if ( + platform === "windows" && + osSettings?.disk_encryption?.status && + isWindowsDiskEncryptionStatus(osSettings.disk_encryption.status) + ) { + const winDiskEncryptionProfile: IHostMdmProfile = generateWinDiskEncryptionProfile( + osSettings.disk_encryption.status + ); + hostMdmProfiles = hostMdmProfiles + ? [...hostMdmProfiles, winDiskEncryptionProfile] + : [winDiskEncryptionProfile]; + } + return (
@@ -198,12 +223,15 @@ const HostSummary = ({ {isPremiumTier && renderHostTeam()} - {platform === "darwin" && + {/* Rendering of OS Settings data */} + {(platform === "darwin" || platform === "windows") && isPremiumTier && - mdmName === "Fleet" && // show if 1 - host is enrolled in Fleet MDM, and + // TODO: API INTEGRATION: change this when we figure out why the API is + // returning "Fleet" or "FleetDM" for the MDM name. + mdmName?.includes("Fleet") && // show if 1 - host is enrolled in Fleet MDM, and hostMdmProfiles && hostMdmProfiles.length > 0 && ( // 2 - host has at least one setting (profile) enforced - + { const statuses = hostMacSettings.map((setting) => setting.status); if (statuses.includes("failed")) { @@ -68,7 +68,7 @@ const getMacProfileStatus = ( }; interface IMacSettingsIndicatorProps { - profiles: IHostMacMdmProfile[]; + profiles: IHostMdmProfile[]; onClick?: () => void; } const MacSettingsIndicator = ({ diff --git a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx index ecb1f1b7c5f1..24a184a0d12b 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx @@ -2,6 +2,7 @@ import { IHostPolicy } from "interfaces/policy"; import React from "react"; import Icon from "components/Icon/Icon"; +import InfoBanner from "components/InfoBanner"; const baseClass = "policy-failing-count"; @@ -18,7 +19,7 @@ const PolicyFailingCount = ({ }, 0); return failCount ? ( -
+
This device is failing @@ -27,10 +28,10 @@ const PolicyFailingCount = ({

Click a policy below to see if there are steps you can take to resolve the issue - {failCount > 1 ? "s" : ""}.{" "} + {failCount > 1 ? "s" : ""}. {deviceUser && " Once resolved, click “Refetch” above to confirm."}

-
+
) : null; }; diff --git a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss index f3543ccc47a6..36ac989fdb94 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss +++ b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss @@ -1,24 +1,11 @@ .policy-failing-count { - font-size: $x-small; - background-color: $ui-off-white; - border: solid 1px $ui-fleet-black-50; - box-sizing: border-box; - border-radius: 10px; - overflow: auto; - margin-bottom: $pad-large; - padding: $pad-large; - padding-bottom: $pad-small; - - p { - padding-left: $pad-large; - margin-top: $pad-medium; - margin-bottom: $pad-medium; - } - &__count { display: flex; - align-content: center; - align-items: center; font-weight: $bold; + gap: $pad-small; + } + + p { + margin-left: $pad-large; // Align second line with first line and not with icon } } diff --git a/frontend/pages/hosts/details/cards/Policies/_styles.scss b/frontend/pages/hosts/details/cards/Policies/_styles.scss index 01a500f3590e..eb7e5d8f6a47 100644 --- a/frontend/pages/hosts/details/cards/Policies/_styles.scss +++ b/frontend/pages/hosts/details/cards/Policies/_styles.scss @@ -1,10 +1,6 @@ .section--policies { .info-banner { margin-bottom: 1rem; - p { - font-size: 0.875rem; - margin: 0; - } } .table-container__header { display: none; diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx index 4b398201f48d..83dd3936d2e5 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx +++ b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ISoftware } from "interfaces/software"; import Icon from "components/Icon/Icon"; +import InfoBanner from "components/InfoBanner"; const baseClass = "software-vuln-count"; @@ -18,14 +19,20 @@ const SoftwareVulnCount = ({ return software.vulnerabilities?.length ? sum + 1 : sum; }, 0); return vulnCount ? ( -
+
{vulnCount === 1 ? "1 software item with vulnerabilities detected" : `${vulnCount} software items with vulnerabilities detected`}
-
+ {!deviceUser && ( +

+ Click a vulnerable item below to see the associated Common + Vulnerabilites and Exposures (CVEs). +

+ )} + ) : ( <> ); diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss index 9562f27e079d..654886d39fb7 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss @@ -1,25 +1,11 @@ .software-vuln-count { - font-size: $x-small; - background-color: $ui-off-white; - border: solid 1px $ui-fleet-black-50; - box-sizing: border-box; - border-radius: 10px; - overflow: auto; - margin-bottom: $pad-large; - padding: $pad-large; - - p { - padding-left: $pad-large; - margin-top: $pad-medium; - margin-bottom: 0; - } - &__count { display: flex; - align-content: center; - align-items: center; - font-size: $x-small; font-weight: $bold; gap: $pad-small; } + + p { + margin-left: $pad-large; // Align second line with first line and not with icon + } } diff --git a/frontend/pages/hosts/details/cards/Software/_styles.scss b/frontend/pages/hosts/details/cards/Software/_styles.scss index e1dc55e7c4ec..ceaae0b40792 100644 --- a/frontend/pages/hosts/details/cards/Software/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/_styles.scss @@ -1,4 +1,7 @@ .section--software { + .info-banner { + margin-bottom: 1rem; + } .text-muted { color: $ui-fleet-black-50; } diff --git a/frontend/pages/hosts/details/helpers.ts b/frontend/pages/hosts/details/helpers.ts new file mode 100644 index 000000000000..2c1283be180e --- /dev/null +++ b/frontend/pages/hosts/details/helpers.ts @@ -0,0 +1,33 @@ +/** Helpers used across the host details and my device pages and components. */ + +import { + IHostMdmProfile, + IWindowsDiskEncryptionStatus, + MdmProfileStatus, +} from "interfaces/mdm"; + +const convertWinDiskEncryptionStatusToProfileStatus = ( + diskEncryptionStatus: IWindowsDiskEncryptionStatus +): MdmProfileStatus => { + return diskEncryptionStatus === "enforcing" + ? "pending" + : diskEncryptionStatus; +}; + +/** + * Manually generates a profile for the windows disk encryption status. We need + * this as we don't have a windows disk encryption profile in the `profiles` + * attribute coming back from the GET /hosts/:id API response. + */ +// eslint-disable-next-line import/prefer-default-export +export const generateWinDiskEncryptionProfile = ( + diskEncryptionStatus: IWindowsDiskEncryptionStatus +): IHostMdmProfile => { + return { + profile_id: 0, // This s the only type of profile that can have this number + name: "Disk Encryption", + status: convertWinDiskEncryptionStatusToProfileStatus(diskEncryptionStatus), + detail: "", + operation_type: null, + }; +}; diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index d8e20f56b27b..bf18675d9494 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -101,7 +101,7 @@ align-items: center; padding-left: $pad-medium; padding-right: $pad-medium; - border-radius: 4px; + border-radius: $border-radius; font-size: $x-small; font-family: "Inter", sans-serif; font-weight: $bold; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss index be5cf1dbb751..376aacc8597e 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss @@ -25,7 +25,7 @@ font-size: $x-small; font-weight: $bold; padding: 2px 4px; - border-radius: 4px; + border-radius: $border-radius; margin-left: 0.5rem; position: relative; top: -2px; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss index ec85f7f7a900..467d05f39eb0 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss @@ -4,7 +4,7 @@ background-color: $ui-off-white; color: $core-fleet-blue; border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; padding: 7px $pad-medium; margin: $pad-large 0 0 44px; } diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index ef3fc1eb4cdd..5d10551229d2 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -19,7 +19,7 @@ import globalPoliciesAPI from "services/entities/global_policies"; import teamPoliciesAPI from "services/entities/team_policies"; import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; -import { QUERIES_PAGE_STEPS } from "utilities/constants"; +import { LIVE_POLICY_STEPS } from "utilities/constants"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import QueryEditor from "pages/policies/PolicyPage/screens/QueryEditor"; @@ -127,7 +127,7 @@ const PolicyPage = ({ }; }, []); - const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]); + const [step, setStep] = useState(LIVE_POLICY_STEPS[1]); const [selectedTargets, setSelectedTargets] = useState([]); const [targetedHosts, setTargetedHosts] = useState([]); const [targetedLabels, setTargetedLabels] = useState([]); @@ -260,7 +260,7 @@ const PolicyPage = ({ storedPolicyError, createPolicy, onOsqueryTableSelect, - goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]), + goToSelectTargets: () => setStep(LIVE_POLICY_STEPS[2]), onOpenSchemaSidebar, renderLiveQueryWarning, }; @@ -272,8 +272,8 @@ const PolicyPage = ({ targetedLabels, targetedTeams, targetsTotalCount, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), - goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]), + goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]), + goToRunQuery: () => setStep(LIVE_POLICY_STEPS[3]), setSelectedTargets, setTargetedHosts, setTargetedLabels, @@ -285,21 +285,21 @@ const PolicyPage = ({ selectedTargets, storedPolicy, setSelectedTargets, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), + goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]), targetsTotalCount, }; switch (step) { - case QUERIES_PAGE_STEPS[2]: + case LIVE_POLICY_STEPS[2]: return ; - case QUERIES_PAGE_STEPS[3]: + case LIVE_POLICY_STEPS[3]: return ; default: return ; } }; - const isFirstStep = step === QUERIES_PAGE_STEPS[1]; + const isFirstStep = step === LIVE_POLICY_STEPS[1]; const showSidebar = isFirstStep && isSidebarOpen && diff --git a/frontend/pages/policies/PolicyPage/_styles.scss b/frontend/pages/policies/PolicyPage/_styles.scss index 262b83b515f5..f06935217810 100644 --- a/frontend/pages/policies/PolicyPage/_styles.scss +++ b/frontend/pages/policies/PolicyPage/_styles.scss @@ -34,33 +34,6 @@ } } - &__observer-query-details { - padding: 0 2rem; - - h1 { - margin: $pad-large 0; - font-size: $large; - } - - p { - margin-bottom: $pad-small; - } - - .sql-button { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - } - } - - &__query-preview { - margin-top: 15px; - - .fleet-ace__label { - display: none; - } - } - .ace_content { min-height: 500px !important; } @@ -86,7 +59,7 @@ background-color: $core-white; border: none; box-shadow: inset 0 0 0 1px $ui-fleet-black-25; - border-radius: 6px; + border-radius: $border-radius-large; cursor: pointer; display: flex; align-items: center; @@ -177,9 +150,4 @@ margin-bottom: 0; } } - .targets-input { - .input-icon-field__icon { - top: 34px; // Override styling to include label header - } - } } diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss index 4e110ad1dd6b..24618c22f97a 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss @@ -3,7 +3,7 @@ &__wrapper { border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; overflow: hidden; margin-top: $pad-medium; } diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss index d0d5d05d2867..65fa20a8a58d 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss @@ -3,7 +3,7 @@ &__wrapper { border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; overflow: hidden; margin-top: $pad-medium; } diff --git a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx index 89e46d024a0a..5e42e673de86 100644 --- a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx +++ b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx @@ -173,7 +173,7 @@ const QueryEditor = ({ return null; } - // Function instead of constant eliminates race condition with filteredSoftwarePath + // Function instead of constant eliminates race condition with filteredPoliciesPath const backToPoliciesPath = () => { return filteredPoliciesPath || PATHS.MANAGE_POLICIES; }; diff --git a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx index b83ea47c99dc..73290cecab6b 100644 --- a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx +++ b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx @@ -10,6 +10,7 @@ import { useQuery } from "react-query"; import { pick } from "lodash"; import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; import { TableContext } from "context/table"; import { NotificationContext } from "context/notification"; import { performanceIndicator } from "utilities/helpers"; @@ -20,8 +21,10 @@ import { IQueryKeyQueriesLoadAll, ISchedulableQuery, } from "interfaces/schedulable_query"; +import { DEFAULT_TARGETS_BY_TYPE } from "interfaces/target"; import queriesAPI from "services/entities/queries"; import PATHS from "router/paths"; +import { DEFAULT_QUERY } from "utilities/constants"; import { checkPlatformCompatibility } from "utilities/sql_tools"; import Button from "components/buttons/Button"; import Spinner from "components/Spinner"; @@ -87,6 +90,9 @@ const ManageQueriesPage = ({ isSandboxMode, config, } = useContext(AppContext); + const { setLastEditedQueryBody, setSelectedQueryTargetsByType } = useContext( + QueryContext + ); const { setResetSelectedRows } = useContext(TableContext); const { renderFlash } = useContext(NotificationContext); @@ -178,7 +184,15 @@ const ManageQueriesPage = ({ } }, [location, filteredQueriesPath, setFilteredQueriesPath]); - const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY(currentTeamId)); + // Reset selected targets when returned to this page + useEffect(() => { + setSelectedQueryTargetsByType(DEFAULT_TARGETS_BY_TYPE); + }, []); + + const onCreateQueryClick = () => { + setLastEditedQueryBody(DEFAULT_QUERY.query); + router.push(PATHS.NEW_QUERY(currentTeamId)); + }; const toggleDeleteQueryModal = useCallback(() => { setShowDeleteQueryModal(!showDeleteQueryModal); diff --git a/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss index a0e403d93525..4acc671a1196 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss +++ b/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss @@ -11,7 +11,7 @@ flex-direction: column; align-items: flex-start; align-self: stretch; - border-radius: 4px; + border-radius: $border-radius; border: 1px solid $ui-fleet-black-10; } diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx index f24c17af24aa..29d25358def8 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx @@ -152,7 +152,7 @@ const generateTableHeaders = ({ )} } - path={PATHS.EDIT_QUERY( + path={PATHS.QUERY( cellProps.row.original.id, cellProps.row.original.team_id ?? undefined )} @@ -183,10 +183,7 @@ const generateTableHeaders = ({ - Assign a frequency and turn automations on to - collect data at an interval. - + <>Assign a frequency to collect data at an interval. } /> ); diff --git a/frontend/pages/queries/QueryPage/QueryPage.tsx b/frontend/pages/queries/QueryPage/QueryPage.tsx deleted file mode 100644 index ca93efe3d479..000000000000 --- a/frontend/pages/queries/QueryPage/QueryPage.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import React, { useState, useEffect, useContext, useCallback } from "react"; -import { useQuery, useMutation } from "react-query"; -import { useErrorHandler } from "react-error-boundary"; -import { InjectedRouter, Params } from "react-router/lib/Router"; - -import { AppContext } from "context/app"; -import { QueryContext } from "context/query"; -import { QUERIES_PAGE_STEPS, DEFAULT_QUERY } from "utilities/constants"; -import queryAPI from "services/entities/queries"; -import hostAPI from "services/entities/hosts"; -import statusAPI from "services/entities/status"; -import { IHost, IHostResponse } from "interfaces/host"; -import { ILabel } from "interfaces/label"; -import { ITeam } from "interfaces/team"; -import { - IGetQueryResponse, - ISchedulableQuery, -} from "interfaces/schedulable_query"; -import { ITarget } from "interfaces/target"; - -import QuerySidePanel from "components/side_panels/QuerySidePanel"; -import MainContent from "components/MainContent"; -import SidePanelContent from "components/SidePanelContent"; -import SelectTargets from "components/LiveQuery/SelectTargets"; -import CustomLink from "components/CustomLink"; - -import QueryEditor from "pages/queries/QueryPage/screens/QueryEditor"; -import RunQuery from "pages/queries/QueryPage/screens/RunQuery"; -import useTeamIdParam from "hooks/useTeamIdParam"; - -interface IQueryPageProps { - router: InjectedRouter; - params: Params; - location: { - pathname: string; - query: { host_ids: string; team_id?: string }; - search: string; - }; -} - -const baseClass = "query-page"; - -const QueryPage = ({ - router, - params: { id: paramsQueryId }, - location, -}: IQueryPageProps): JSX.Element => { - const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; - const { - currentTeamName: teamNameForQuery, - teamIdForApi: apiTeamIdForQuery, - } = useTeamIdParam({ - location, - router, - includeAllTeams: true, - includeNoTeam: false, - }); - - const handlePageError = useErrorHandler(); - const { - isGlobalAdmin, - isGlobalMaintainer, - isAnyTeamMaintainerOrTeamAdmin, - isObserverPlus, - isAnyTeamObserverPlus, - } = useContext(AppContext); - const { - selectedOsqueryTable, - setSelectedOsqueryTable, - setLastEditedQueryId, - setLastEditedQueryName, - setLastEditedQueryDescription, - setLastEditedQueryBody, - setLastEditedQueryObserverCanRun, - setLastEditedQueryFrequency, - setLastEditedQueryLoggingType, - setLastEditedQueryMinOsqueryVersion, - setLastEditedQueryPlatforms, - } = useContext(QueryContext); - - const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); - const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]); - const [selectedTargets, setSelectedTargets] = useState([]); - const [targetedHosts, setTargetedHosts] = useState([]); - const [targetedLabels, setTargetedLabels] = useState([]); - const [targetedTeams, setTargetedTeams] = useState([]); - const [targetsTotalCount, setTargetsTotalCount] = useState(0); - const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); - const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState( - false - ); - - // disabled on page load so we can control the number of renders - // else it will re-populate the context on occasion - const { - isLoading: isStoredQueryLoading, - data: storedQuery, - error: storedQueryError, - } = useQuery( - ["query", queryId], - () => queryAPI.load(queryId as number), - { - enabled: !!queryId, - refetchOnWindowFocus: false, - select: (data) => data.query, - onSuccess: (returnedQuery) => { - setLastEditedQueryId(returnedQuery.id); - setLastEditedQueryName(returnedQuery.name); - setLastEditedQueryDescription(returnedQuery.description); - setLastEditedQueryBody(returnedQuery.query); - setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); - setLastEditedQueryFrequency(returnedQuery.interval); - setLastEditedQueryPlatforms(returnedQuery.platform); - setLastEditedQueryLoggingType(returnedQuery.logging); - setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); - }, - onError: (error) => handlePageError(error), - } - ); - - useQuery( - "hostFromURL", - () => - hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), - { - enabled: !!location.query.host_ids && !queryParamHostsAdded, - select: (data: IHostResponse) => data.host, - onSuccess: (host) => { - setTargetedHosts((prevHosts) => - prevHosts.filter((h) => h.id !== host.id).concat(host) - ); - const targets = selectedTargets; - host.target_type = "hosts"; - targets.push(host); - setSelectedTargets([...targets]); - if (!queryParamHostsAdded) { - setQueryParamHostsAdded(true); - } - router.replace(location.pathname); - }, - } - ); - - const detectIsFleetQueryRunnable = () => { - statusAPI.live_query().catch(() => { - setIsLiveQueryRunnable(false); - }); - }; - - useEffect(() => { - detectIsFleetQueryRunnable(); - if (!queryId) { - setLastEditedQueryId(DEFAULT_QUERY.id); - setLastEditedQueryName(DEFAULT_QUERY.name); - setLastEditedQueryDescription(DEFAULT_QUERY.description); - setLastEditedQueryBody(DEFAULT_QUERY.query); - setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run); - setLastEditedQueryFrequency(DEFAULT_QUERY.interval); - setLastEditedQueryLoggingType(DEFAULT_QUERY.logging); - setLastEditedQueryMinOsqueryVersion(DEFAULT_QUERY.min_osquery_version); - setLastEditedQueryPlatforms(DEFAULT_QUERY.platform); - } - }, [queryId]); - - // Updates title that shows up on browser tabs - useEffect(() => { - // e.g., Query details | Discover TLS certificates | Fleet for osquery - document.title = `Query details | ${storedQuery?.name} | Fleet for osquery`; - }, [location.pathname, storedQuery?.name]); - - useEffect(() => { - setShowOpenSchemaActionText(!isSidebarOpen); - }, [isSidebarOpen]); - - const onOsqueryTableSelect = (tableName: string) => { - setSelectedOsqueryTable(tableName); - }; - - const onCloseSchemaSidebar = () => { - setIsSidebarOpen(false); - }; - - const onOpenSchemaSidebar = () => { - setIsSidebarOpen(true); - }; - - const renderLiveQueryWarning = (): JSX.Element | null => { - if (isLiveQueryRunnable) { - return null; - } - - return ( -
-
-

- Fleet is unable to run a live query. Refresh the page or log in - again. If this keeps happening please{" "} - -

-
-
- ); - }; - - const goToQueryEditor = useCallback(() => setStep(QUERIES_PAGE_STEPS[1]), []); - - const renderScreen = () => { - const step1Props = { - router, - baseClass, - queryIdForEdit: queryId, - teamNameForQuery, - apiTeamIdForQuery, - showOpenSchemaActionText, - storedQuery, - isStoredQueryLoading, - storedQueryError, - onOsqueryTableSelect, - goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]), - onOpenSchemaSidebar, - renderLiveQueryWarning, - }; - - const step2Props = { - baseClass, - queryId, - selectedTargets, - targetedHosts, - targetedLabels, - targetedTeams, - targetsTotalCount, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), - goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]), - setSelectedTargets, - setTargetedHosts, - setTargetedLabels, - setTargetedTeams, - setTargetsTotalCount, - }; - - const step3Props = { - queryId, - selectedTargets, - storedQuery, - setSelectedTargets, - goToQueryEditor, - targetsTotalCount, - }; - - switch (step) { - case QUERIES_PAGE_STEPS[2]: - return ; - case QUERIES_PAGE_STEPS[3]: - return ; - default: - return ; - } - }; - - const isFirstStep = step === QUERIES_PAGE_STEPS[1]; - const showSidebar = - isFirstStep && - isSidebarOpen && - (isGlobalAdmin || - isGlobalMaintainer || - isAnyTeamMaintainerOrTeamAdmin || - isObserverPlus || - isAnyTeamObserverPlus); - - return ( - <> - -
{renderScreen()}
-
- {showSidebar && ( - - - - )} - - ); -}; - -export default QueryPage; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/index.ts b/frontend/pages/queries/QueryPage/components/QueryForm/index.ts deleted file mode 100644 index 6bc72d25d477..000000000000 --- a/frontend/pages/queries/QueryPage/components/QueryForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./QueryForm"; diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss b/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss deleted file mode 100644 index a4d1337350e1..000000000000 --- a/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss +++ /dev/null @@ -1,32 +0,0 @@ -.save-query-modal { - .fleet-checkbox { - display: flex; - align-items: center; - } - - .help-text { - margin-top: $pad-small; - margin-bottom: $pad-large; - font-weight: $regular; - font-size: 0.75rem; - color: $ui-fleet-black-75; - } - - &__form-field { - &--frequency { - margin-bottom: 0; - } - &--platform { - margin-bottom: 0; - margin-top: $pad-large; - } - } - - &__observer-can-run-wrapper { - margin-bottom: 0; - } - - &__advanced-options-toggle { - font-weight: $xbold; - } -} diff --git a/frontend/pages/queries/QueryPage/index.ts b/frontend/pages/queries/QueryPage/index.ts deleted file mode 100644 index 8d00fa047549..000000000000 --- a/frontend/pages/queries/QueryPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./QueryPage"; diff --git a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx b/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx deleted file mode 100644 index 7b94956efd4c..000000000000 --- a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useContext, useEffect, useState } from "react"; - -import { InjectedRouter } from "react-router/lib/Router"; -import { UseMutateAsyncFunction } from "react-query"; - -import queryAPI from "services/entities/queries"; -import { AppContext } from "context/app"; -import { QueryContext } from "context/query"; -import { NotificationContext } from "context/notification"; -import { - ICreateQueryRequestBody, - ISchedulableQuery, -} from "interfaces/schedulable_query"; -import PATHS from "router/paths"; -import debounce from "utilities/debounce"; -import deepDifference from "utilities/deep_difference"; - -import BackLink from "components/BackLink"; -import QueryForm from "pages/queries/QueryPage/components/QueryForm"; - -interface IQueryEditorProps { - router: InjectedRouter; - baseClass: string; - queryIdForEdit: number | null; - teamNameForQuery?: string; - apiTeamIdForQuery?: number; - storedQuery: ISchedulableQuery | undefined; - storedQueryError: Error | null; - showOpenSchemaActionText: boolean; - isStoredQueryLoading: boolean; - onOsqueryTableSelect: (tableName: string) => void; - goToSelectTargets: () => void; - onOpenSchemaSidebar: () => void; - renderLiveQueryWarning: () => JSX.Element | null; -} - -const QueryEditor = ({ - router, - baseClass, - queryIdForEdit, - teamNameForQuery, - apiTeamIdForQuery, - storedQuery, - storedQueryError, - showOpenSchemaActionText, - isStoredQueryLoading, - onOsqueryTableSelect, - goToSelectTargets, - onOpenSchemaSidebar, - renderLiveQueryWarning, -}: IQueryEditorProps): JSX.Element | null => { - const { currentUser, filteredQueriesPath } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); - - // Note: The QueryContext values should always be used for any mutable query data such as query name - // The storedQuery prop should only be used to access immutable metadata such as author id - const { - lastEditedQueryName, - lastEditedQueryDescription, - lastEditedQueryBody, - lastEditedQueryObserverCanRun, - lastEditedQueryFrequency, - lastEditedQueryLoggingType, - lastEditedQueryPlatforms, - lastEditedQueryMinOsqueryVersion, - } = useContext(QueryContext); - - const [isQuerySaving, setIsQuerySaving] = useState(false); - const [isQueryUpdating, setIsQueryUpdating] = useState(false); - - useEffect(() => { - if (storedQueryError) { - renderFlash( - "error", - "Something went wrong retrieving your query. Please try again." - ); - } - }, []); - - const [backendValidators, setBackendValidators] = useState<{ - [key: string]: string; - }>({}); - - const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => { - setIsQuerySaving(true); - try { - const { query } = await queryAPI.create(formData); - router.push(PATHS.EDIT_QUERY(query.id)); - renderFlash("success", "Query created!"); - setBackendValidators({}); - } catch (createError: any) { - if (createError.data.errors[0].reason.includes("already exists")) { - const teamErrorText = - teamNameForQuery && apiTeamIdForQuery !== 0 - ? `the ${teamNameForQuery} team` - : "all teams"; - setBackendValidators({ - name: `A query with that name already exists for ${teamErrorText}.`, - }); - } else { - renderFlash( - "error", - "Something went wrong creating your query. Please try again." - ); - setBackendValidators({}); - } - } finally { - setIsQuerySaving(false); - } - }); - - const onUpdateQuery = async (formData: ICreateQueryRequestBody) => { - if (!queryIdForEdit) { - return false; - } - - setIsQueryUpdating(true); - - const updatedQuery = deepDifference(formData, { - lastEditedQueryName, - lastEditedQueryDescription, - lastEditedQueryBody, - lastEditedQueryObserverCanRun, - lastEditedQueryFrequency, - lastEditedQueryPlatforms, - lastEditedQueryLoggingType, - lastEditedQueryMinOsqueryVersion, - }); - - try { - await queryAPI.update(queryIdForEdit, updatedQuery); - renderFlash("success", "Query updated!"); - } catch (updateError: any) { - console.error(updateError); - if (updateError.data.errors[0].reason.includes("Duplicate")) { - renderFlash("error", "A query with this name already exists."); - } else { - renderFlash( - "error", - "Something went wrong updating your query. Please try again." - ); - } - } - - setIsQueryUpdating(false); - - return false; - }; - - if (!currentUser) { - return null; - } - - // Function instead of constant eliminates race condition with filteredSoftwarePath - const backToQueriesPath = () => { - return filteredQueriesPath || PATHS.MANAGE_QUERIES; - }; - - return ( -
-
- -
- -
- ); -}; - -export default QueryEditor; diff --git a/frontend/pages/queries/QueryPage/screens/test.js b/frontend/pages/queries/QueryPage/screens/test.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx new file mode 100644 index 000000000000..f375a88b00e3 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -0,0 +1,295 @@ +import React, { useContext } from "react"; +import { useQuery } from "react-query"; +import { InjectedRouter, Params } from "react-router/lib/Router"; +import { useErrorHandler } from "react-error-boundary"; + +import PATHS from "router/paths"; +import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; + +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; +import { IQueryReport } from "interfaces/query_report"; + +import queryAPI from "services/entities/queries"; +import queryReportAPI, { ISortOption } from "services/entities/query_report"; + +import Spinner from "components/Spinner/Spinner"; +import Button from "components/buttons/Button"; +import BackLink from "components/BackLink"; +import MainContent from "components/MainContent"; +import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; +import QueryAutomationsStatusIndicator from "pages/queries/ManageQueriesPage/components/QueryAutomationsStatusIndicator/QueryAutomationsStatusIndicator"; +import DataError from "components/DataError/DataError"; +import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator"; +import CustomLink from "components/CustomLink"; +import InfoBanner from "components/InfoBanner"; +import QueryReport from "../components/QueryReport/QueryReport"; +import NoResults from "../components/NoResults/NoResults"; + +import { + DEFAULT_SORT_HEADER, + DEFAULT_SORT_DIRECTION, + QUERY_REPORT_RESULTS_LIMIT, +} from "./QueryDetailsPageConfig"; + +interface IQueryDetailsPageProps { + router: InjectedRouter; // v3 + params: Params; + location: { + pathname: string; + query: { team_id?: string; order_key?: string; order_direction?: string }; + search: string; + }; +} + +const baseClass = "query-details-page"; + +const QueryDetailsPage = ({ + router, + params: { id: paramsQueryId }, + location, +}: IQueryDetailsPageProps): JSX.Element => { + const queryId = parseInt(paramsQueryId, 10); + const queryParams = location.query; + + // Functions to avoid race conditions + const serverSortBy: ISortOption[] = (() => { + return [ + { + key: queryParams?.order_key ?? DEFAULT_SORT_HEADER, + direction: queryParams?.order_direction ?? DEFAULT_SORT_DIRECTION, + }, + ]; + })(); + + const handlePageError = useErrorHandler(); + const { + isGlobalAdmin, + isGlobalMaintainer, + isAnyTeamMaintainerOrTeamAdmin, + isObserverPlus, + isAnyTeamObserverPlus, + config, + filteredQueriesPath, + } = useContext(AppContext); + const { + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryObserverCanRun, + setLastEditedQueryId, + setLastEditedQueryName, + setLastEditedQueryDescription, + setLastEditedQueryBody, + setLastEditedQueryObserverCanRun, + setLastEditedQueryFrequency, + setLastEditedQueryLoggingType, + setLastEditedQueryMinOsqueryVersion, + setLastEditedQueryPlatforms, + } = useContext(QueryContext); + + // Title that shows up on browser tabs (e.g., Query details | Discover TLS certificates | Fleet for osquery) + document.title = `Query details | ${lastEditedQueryName} | Fleet for osquery`; + + // disabled on page load so we can control the number of renders + // else it will re-populate the context on occasion + const { + isLoading: isStoredQueryLoading, + data: storedQuery, + error: storedQueryError, + } = useQuery( + ["query", queryId], + () => queryAPI.load(queryId), + { + enabled: !!queryId, + refetchOnWindowFocus: false, + select: (data) => data.query, + onSuccess: (returnedQuery) => { + setLastEditedQueryId(returnedQuery.id); + setLastEditedQueryName(returnedQuery.name); + setLastEditedQueryDescription(returnedQuery.description); + setLastEditedQueryBody(returnedQuery.query); + setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); + setLastEditedQueryFrequency(returnedQuery.interval); + setLastEditedQueryPlatforms(returnedQuery.platform); + setLastEditedQueryLoggingType(returnedQuery.logging); + setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); + }, + onError: (error) => handlePageError(error), + } + ); + + const { + isLoading: isQueryReportLoading, + data: queryReport, + error: queryReportError, + } = useQuery( + [], + () => + queryReportAPI.load({ + sortBy: serverSortBy, + id: queryId, + }), + { + enabled: !!queryId, + refetchOnWindowFocus: false, + onError: (error) => handlePageError(error), + } + ); + + const isLoading = isStoredQueryLoading || isQueryReportLoading; + const isApiError = storedQueryError || queryReportError; + const isClipped = + queryReport && queryReport.results.length >= QUERY_REPORT_RESULTS_LIMIT; + + const renderHeader = () => { + const canEditQuery = + isGlobalAdmin || isGlobalMaintainer || isAnyTeamMaintainerOrTeamAdmin; + + // Function instead of constant eliminates race condition with filteredQueriesPath + const backToQueriesPath = () => { + return filteredQueriesPath || PATHS.MANAGE_QUERIES; + }; + + return ( + <> +
+ +
+
+ {!isLoading && !isApiError && ( +
+
+

+ {lastEditedQueryName} +

+

+ {lastEditedQueryDescription} +

+
+
+ {canEditQuery && ( + + )} + {(lastEditedQueryObserverCanRun || + isObserverPlus || + isAnyTeamObserverPlus || + canEditQuery) && ( +
+ +
+ )} +
+
+ )} + {!isLoading && !isApiError && ( +
+
+ on, data is sent according to a query’s frequency.`} + > + Automations: + + +
+
+ Log destination:{" "} + +
+
+ )} +
+ + ); + }; + + const renderClippedBanner = () => ( + + } + > +
+ Report clipped. A sample of this query's results is included + below. You can still use query automations to complete this report in + your log destination. +
+
+ ); + + const renderReport = () => { + const disabledCachingGlobally = true; // TODO: Update accordingly to config?.server_settings.query_reports_disabled + const discardDataEnabled = true; // TODO: Update accordingly to storedQuery?.discard_data + const loggingSnapshot = storedQuery?.logging === "snapshot"; + const disabledCaching = + disabledCachingGlobally || discardDataEnabled || !loggingSnapshot; + const emptyCache = queryReport?.results.length === 0; // TODO: Update with API response + + // Loading state + if (isLoading) { + return ; + } + + // Error state + if (isApiError) { + return ; + } + + // Empty state with varying messages explaining why there's no results + if (emptyCache) { + return ( + + ); + } + return ; + }; + + return ( + +
+ {renderHeader()} + {isClipped && renderClippedBanner()} + {renderReport()} +
+
+ ); +}; + +export default QueryDetailsPage; diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx new file mode 100644 index 000000000000..05ef2ba60479 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx @@ -0,0 +1,15 @@ +// TODO +export const QUERY_DETAILS_PAGE_FILTER_KEYS = ["model", "vendor"] as const; + +// TODO: refactor to use this type as the location.query prop of the page +export type QueryDetailsPageQueryParams = Record< + | "order_key" + | "order_direction" + | typeof QUERY_DETAILS_PAGE_FILTER_KEYS[number], + string +>; + +export const DEFAULT_SORT_HEADER = "host_name"; +export const DEFAULT_SORT_DIRECTION = "asc"; + +export const QUERY_REPORT_RESULTS_LIMIT = 1000; diff --git a/frontend/pages/queries/details/QueryDetailsPage/_styles.scss b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss new file mode 100644 index 000000000000..dd754e830a34 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss @@ -0,0 +1,74 @@ +.query-details-page { + &__wrapper { + display: flex; + flex-direction: column; + gap: $pad-large; + } + &__title-bar { + display: flex; + justify-content: space-between; + margin-top: $pad-small; + + .name-description { + display: flex; + flex-direction: column; + gap: $pad-small; + } + } + + &__action-button-container { + display: flex; + justify-content: flex-end; + min-width: 266px; + gap: $pad-medium; + } + + &__query-name { + margin-top: 0; + font-size: $large; + } + + &__query-description { + margin-top: 0; + margin-bottom: $pad-small; + font-size: $x-small; + } + + &__settings { + display: flex; + gap: $pad-large; + font-size: $x-small; + } + + &__automations, + &__log-destination { + display: flex; + gap: $pad-small; + + .component__tooltip-wrapper__element { + font-weight: $bold; + } + } + + .empty-table__inner { + .component__tooltip-wrapper__tip-text { + text-align: left; + width: 320px; + } + + ul { + color: $core-white; + + li { + &::before { + content: "•"; + color: $core-white; + } + } + } + } + + .data-error { + padding-top: $pad-xxxlarge; + } +} diff --git a/frontend/pages/queries/details/QueryDetailsPage/index.ts b/frontend/pages/queries/details/QueryDetailsPage/index.ts new file mode 100644 index 000000000000..9bb526e7b5d9 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./QueryDetailsPage"; diff --git a/frontend/pages/queries/details/components/NoResults/NoResults.tsx b/frontend/pages/queries/details/components/NoResults/NoResults.tsx new file mode 100644 index 000000000000..165792333953 --- /dev/null +++ b/frontend/pages/queries/details/components/NoResults/NoResults.tsx @@ -0,0 +1,115 @@ +import React from "react"; + +import differenceInSeconds from "date-fns/differenceInSeconds"; +import formatDistance from "date-fns/formatDistance"; +import add from "date-fns/add"; + +import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; +import EmptyTable from "components/EmptyTable/EmptyTable"; + +interface INoResultsProps { + queryInterval?: number; + queryUpdatedAt?: string; + disabledCaching: boolean; + disabledCachingGlobally: boolean; + discardDataEnabled: boolean; + loggingSnapshot: boolean; +} + +const baseClass = "no-results"; + +const NoResults = ({ + queryInterval, + queryUpdatedAt, + disabledCaching, + disabledCachingGlobally, + discardDataEnabled, + loggingSnapshot, +}: INoResultsProps): JSX.Element => { + // Returns how many seconds it takes to expect a cached update + const secondsCheckbackTime = () => { + const secondsSinceUpdate = queryUpdatedAt + ? differenceInSeconds(new Date(), new Date(queryUpdatedAt)) + : 0; + const secondsUpdateWaittime = (queryInterval || 0) + 60; + return secondsUpdateWaittime - secondsSinceUpdate; + }; + + // Update status of collecting cached results + const collectingResults = secondsCheckbackTime() > 0; + + // Converts seconds takes to update to human readable format + const readableCheckbackTime = formatDistance( + add(new Date(), { seconds: secondsCheckbackTime() }), + new Date() + ); + + // Collecting results state + if (collectingResults) { + const collectingResultsInfo = () => + `Fleet is collecting query results. Check back in about ${readableCheckbackTime}.`; + + return ( + + ); + } + + const noResultsInfo = () => { + if (!queryInterval) { + return ( + <> + This query does not collect data on a schedule. Add a{" "} + frequency or run this as a live query to see results. + + ); + } + if (disabledCaching) { + const tipContent = () => { + if (disabledCachingGlobally) { + return "The following setting prevents saving this query's results in Fleet:
  • Query reports are globally disabled in organization settings.
"; + } + if (discardDataEnabled) { + return "The following setting prevents saving this query's results in Fleet:
  • This query has Discard data enabled.
"; + } + if (!loggingSnapshot) { + return "The following setting prevents saving this query's results in Fleet:
  • The logging setting for this query is not Snapshot.
"; + } + return "Unknown"; + }; + return ( + <> + Results from this query are{" "} + + not reported in Fleet + + . + + ); + } + // No errors will be reported in V1 + // if (errorsOnly) { + // return ( + // <> + // This query had trouble collecting data on some hosts. Check out the{" "} + // Errors tab to see why. + // + // ); + // } + return "This query has returned no data so far."; + }; + + return ( + + ); +}; + +export default NoResults; diff --git a/frontend/pages/queries/details/components/NoResults/index.ts b/frontend/pages/queries/details/components/NoResults/index.ts new file mode 100644 index 000000000000..04bef19e7728 --- /dev/null +++ b/frontend/pages/queries/details/components/NoResults/index.ts @@ -0,0 +1 @@ +export { default } from "./NoResults"; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx new file mode 100644 index 000000000000..ca51bdc450f1 --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx @@ -0,0 +1,172 @@ +import React, { useState, useContext, useEffect, useCallback } from "react"; + +import { Row, Column } from "react-table"; +import FileSaver from "file-saver"; +import { QueryContext } from "context/query"; + +import { + generateCSVFilename, + generateCSVQueryResults, +} from "utilities/generate_csv"; +import { IQueryReport, IQueryReportResultRow } from "interfaces/query_report"; + +import Button from "components/buttons/Button"; +import Icon from "components/Icon/Icon"; +import TableContainer from "components/TableContainer"; +import ShowQueryModal from "components/modals/ShowQueryModal"; +import TooltipWrapper from "components/TooltipWrapper"; + +import generateResultsTableHeaders from "./QueryReportTableConfig"; + +interface IQueryReportProps { + queryReport?: IQueryReport; + isClipped?: boolean; +} + +const baseClass = "query-report"; +const CSV_TITLE = "Query"; + +const tableResults = (results: IQueryReportResultRow[]) => { + return results.map((result: IQueryReportResultRow) => { + const hostInfoColumns = { + host_display_name: result.host_name, + last_fetched: result.last_fetched, + }; + + // hostInfoColumns displays the host metadata that is returned with every query + // result.columns are the variable columns returned by the API that differ per query + return { ...hostInfoColumns, ...result.columns }; + }); +}; + +const QueryReport = ({ + queryReport, + isClipped, +}: IQueryReportProps): JSX.Element => { + const { lastEditedQueryName, lastEditedQueryBody } = useContext(QueryContext); + + const [showQueryModal, setShowQueryModal] = useState(false); + const [filteredResults, setFilteredResults] = useState( + tableResults(queryReport?.results || []) + ); + const [tableHeaders, setTableHeaders] = useState([]); + + useEffect(() => { + if (queryReport && queryReport.results && queryReport.results.length > 0) { + const generatedTableHeaders = generateResultsTableHeaders( + tableResults(queryReport.results) + ); + // Update tableHeaders if new headers are found + if (generatedTableHeaders !== tableHeaders) { + setTableHeaders(generatedTableHeaders); + } + } + }, [queryReport]); // Cannot use tableHeaders as it will cause infinite loop with setTableHeaders + + const onExportQueryResults = (evt: React.MouseEvent) => { + evt.preventDefault(); + FileSaver.saveAs( + generateCSVQueryResults( + filteredResults, + generateCSVFilename( + `${lastEditedQueryName || CSV_TITLE} - Query Report` + ), + tableHeaders + ) + ); + }; + + const onShowQueryModal = () => { + setShowQueryModal(!showQueryModal); + }; + + const renderNoResults = () => { + return

TODO

; + }; + + const renderTableButtons = () => { + return ( +
+ + +
+ ); + }; + + const renderResultsCount = useCallback(() => { + const count = filteredResults.length; + + if (isClipped) { + return ( +
+
+ You can reset this report by updating the query's SQL, or by + temporarily enabling the discard data setting and disabling it again.`} + > + {`${count} result${count === 1 ? "" : "s"}`} +
+
+ ); + } + return ( +
+ {`${count} result${count === 1 ? "" : "s"}`} +
+ ); + }, [filteredResults.length, isClipped]); + + const renderTable = () => { + return ( +
+ renderTableButtons()} + setExportRows={setFilteredResults} + renderCount={renderResultsCount} + /> +
+ ); + }; + + return ( +
+ {renderTable()} + {showQueryModal && ( + + )} +
+ ); +}; + +export default QueryReport; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx new file mode 100644 index 000000000000..05a906fae785 --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx @@ -0,0 +1,93 @@ +/* eslint-disable react/prop-types */ +// disable this rule as it was throwing an error in Header and Cell component +// definitions for the selection row for some reason when we dont really need it. +import React from "react"; + +import { + CellProps, + Column, + ColumnInstance, + ColumnInterface, + HeaderProps, + TableInstance, +} from "react-table"; + +import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter"; +import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; + +import { humanHostLastSeen } from "utilities/helpers"; + +type IHeaderProps = HeaderProps & { + column: ColumnInstance & IDataColumn; +}; + +type ICellProps = CellProps; + +interface IDataColumn extends ColumnInterface { + title?: string; + accessor: string; +} + +const _unshiftHostname = (headers: IDataColumn[]) => { + const newHeaders = [...headers]; + const displayNameIndex = headers.findIndex( + (h) => h.id === "host_display_name" + ); + if (displayNameIndex >= 0) { + // remove hostname header from headers + const [displayNameHeader] = newHeaders.splice(displayNameIndex, 1); + // reformat title and insert at start of headers array + newHeaders.unshift({ ...displayNameHeader, title: "Host" }); + } + // TODO: Remove after v5 when host_hostname is removed rom API response. + const hostNameIndex = headers.findIndex((h) => h.id === "host_hostname"); + if (hostNameIndex >= 0) { + newHeaders.splice(hostNameIndex, 1); + } + // end remove + return newHeaders; +}; + +const generateResultsTableHeaders = (results: any[]): Column[] => { + /* Results include an array of objects, each representing a table row + Each key value pair in an object represents a column name and value + To create headers, use JS set to create an array of all unique column names */ + const uniqueColumnNames = Array.from( + results.reduce( + (s, o) => Object.keys(o).reduce((t, k) => t.add(k), s), + new Set() // Set prevents listing duplicate headers + ) + ); + + const headers = uniqueColumnNames.map((key) => { + return { + id: key as string, + title: key as string, + Header: (headerProps: IHeaderProps) => ( + + ), + accessor: key as string, + Cell: (cellProps: ICellProps) => { + // Sorts chronologically by date, but UI displays readable last fetched + if (cellProps.column.id === "last_fetched") { + return humanHostLastSeen(cellProps?.cell?.value); + } + return cellProps?.cell?.value || null; + }, + Filter: DefaultColumnFilter, // Component hides filter for last_fetched + filterType: "text", + disableSortBy: false, + }; + }); + return _unshiftHostname(headers); +}; + +export default generateResultsTableHeaders; diff --git a/frontend/pages/queries/details/components/QueryReport/_styles.scss b/frontend/pages/queries/details/components/QueryReport/_styles.scss new file mode 100644 index 000000000000..ecfcde77c816 --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/_styles.scss @@ -0,0 +1,18 @@ +.query-report { + &__wrapper { + .host_id__header { + width: 95px; // Min width for 6 digits host IDs + } + + .last_fetched__header { + .column-header { + margin-bottom: 44px; // Fills space where filter is removed + } + } + } + + &__results-cta { + display: flex; + gap: $pad-medium; + } +} diff --git a/frontend/pages/queries/details/components/QueryReport/index.ts b/frontend/pages/queries/details/components/QueryReport/index.ts new file mode 100644 index 000000000000..7e9fe702db5c --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/index.ts @@ -0,0 +1 @@ +export { default } from "./QueryReport"; diff --git a/frontend/pages/queries/edit/EditQueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage.tsx new file mode 100644 index 000000000000..f0d164a8a5e3 --- /dev/null +++ b/frontend/pages/queries/edit/EditQueryPage.tsx @@ -0,0 +1,346 @@ +import React, { useState, useEffect, useContext } from "react"; +import { useQuery } from "react-query"; +import { useErrorHandler } from "react-error-boundary"; +import { InjectedRouter, Params } from "react-router/lib/Router"; + +import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; +import { DEFAULT_QUERY } from "utilities/constants"; +import configAPI from "services/entities/config"; +import queryAPI from "services/entities/queries"; +import statusAPI from "services/entities/status"; +import { + IGetQueryResponse, + ICreateQueryRequestBody, + ISchedulableQuery, +} from "interfaces/schedulable_query"; + +import QuerySidePanel from "components/side_panels/QuerySidePanel"; +import MainContent from "components/MainContent"; +import SidePanelContent from "components/SidePanelContent"; +import CustomLink from "components/CustomLink"; + +import useTeamIdParam from "hooks/useTeamIdParam"; + +import { NotificationContext } from "context/notification"; + +import PATHS from "router/paths"; +import debounce from "utilities/debounce"; +import deepDifference from "utilities/deep_difference"; + +import BackLink from "components/BackLink"; +import EditQueryForm from "pages/queries/edit/components/EditQueryForm"; +import { IConfig } from "interfaces/config"; + +interface IEditQueryPageProps { + router: InjectedRouter; + params: Params; + location: { + pathname: string; + query: { host_ids: string; team_id?: string }; + search: string; + }; +} + +const baseClass = "edit-query-page"; + +const EditQueryPage = ({ + router, + params: { id: paramsQueryId }, + location, +}: IEditQueryPageProps): JSX.Element => { + const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + const { + currentTeamName: teamNameForQuery, + teamIdForApi: apiTeamIdForQuery, + } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); + + const handlePageError = useErrorHandler(); + const { + isGlobalAdmin, + isGlobalMaintainer, + isAnyTeamMaintainerOrTeamAdmin, + isObserverPlus, + isAnyTeamObserverPlus, + } = useContext(AppContext); + const { + selectedOsqueryTable, + setSelectedOsqueryTable, + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryBody, + lastEditedQueryObserverCanRun, + lastEditedQueryFrequency, + lastEditedQueryPlatforms, + lastEditedQueryLoggingType, + lastEditedQueryMinOsqueryVersion, + setLastEditedQueryId, + setLastEditedQueryName, + setLastEditedQueryDescription, + setLastEditedQueryBody, + setLastEditedQueryObserverCanRun, + setLastEditedQueryFrequency, + setLastEditedQueryLoggingType, + setLastEditedQueryMinOsqueryVersion, + setLastEditedQueryPlatforms, + } = useContext(QueryContext); + const { setConfig } = useContext(AppContext); + const { renderFlash } = useContext(NotificationContext); + + const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState( + false + ); + const [ + showConfirmSaveChangesModal, + setShowConfirmSaveChangesModal, + ] = useState(false); + + const { data: appConfig } = useQuery( + ["config"], + () => configAPI.loadAll(), + { + select: (data: IConfig) => data, + onSuccess: (data) => { + setConfig(data); + }, + } + ); + + // disabled on page load so we can control the number of renders + // else it will re-populate the context on occasion + const { + isLoading: isStoredQueryLoading, + data: storedQuery, + refetch: refetchStoredQuery, + } = useQuery( + ["query", queryId], + () => queryAPI.load(queryId as number), + { + enabled: !!queryId, + refetchOnWindowFocus: false, + select: (data) => data.query, + onSuccess: (returnedQuery) => { + setLastEditedQueryId(returnedQuery.id); + setLastEditedQueryName(returnedQuery.name); + setLastEditedQueryDescription(returnedQuery.description); + setLastEditedQueryBody(returnedQuery.query); + setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); + setLastEditedQueryFrequency(returnedQuery.interval); + setLastEditedQueryPlatforms(returnedQuery.platform); + setLastEditedQueryLoggingType(returnedQuery.logging); + setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); + }, + onError: (error) => handlePageError(error), + } + ); + + const detectIsFleetQueryRunnable = () => { + statusAPI.live_query().catch(() => { + setIsLiveQueryRunnable(false); + }); + }; + + useEffect(() => { + detectIsFleetQueryRunnable(); + if (!queryId) { + setLastEditedQueryId(DEFAULT_QUERY.id); + setLastEditedQueryName(DEFAULT_QUERY.name); + setLastEditedQueryDescription(DEFAULT_QUERY.description); + // Persist lastEditedQueryBody through live query flow instead of resetting to DEFAULT_QUERY.query + setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run); + setLastEditedQueryFrequency(DEFAULT_QUERY.interval); + setLastEditedQueryLoggingType(DEFAULT_QUERY.logging); + setLastEditedQueryMinOsqueryVersion(DEFAULT_QUERY.min_osquery_version); + setLastEditedQueryPlatforms(DEFAULT_QUERY.platform); + } + }, [queryId]); + + const [isQuerySaving, setIsQuerySaving] = useState(false); + const [isQueryUpdating, setIsQueryUpdating] = useState(false); + const [backendValidators, setBackendValidators] = useState<{ + [key: string]: string; + }>({}); + + // Updates title that shows up on browser tabs + useEffect(() => { + // e.g., Query details | Discover TLS certificates | Fleet for osquery + document.title = `Edit query | ${storedQuery?.name} | Fleet for osquery`; + }, [location.pathname, storedQuery?.name]); + + useEffect(() => { + setShowOpenSchemaActionText(!isSidebarOpen); + }, [isSidebarOpen]); + + const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => { + setIsQuerySaving(true); + try { + const { query } = await queryAPI.create(formData); + router.push(PATHS.EDIT_QUERY(query.id)); + renderFlash("success", "Query created!"); + setBackendValidators({}); + } catch (createError: any) { + if (createError.data.errors[0].reason.includes("already exists")) { + const teamErrorText = + teamNameForQuery && apiTeamIdForQuery !== 0 + ? `the ${teamNameForQuery} team` + : "all teams"; + setBackendValidators({ + name: `A query with that name already exists for ${teamErrorText}.`, + }); + } else { + renderFlash( + "error", + "Something went wrong creating your query. Please try again." + ); + setBackendValidators({}); + } + } finally { + setIsQuerySaving(false); + } + }); + + const onUpdateQuery = async (formData: ICreateQueryRequestBody) => { + if (!queryId) { + return false; + } + + setIsQueryUpdating(true); + + const updatedQuery = deepDifference(formData, { + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryBody, + lastEditedQueryObserverCanRun, + lastEditedQueryFrequency, + lastEditedQueryPlatforms, + lastEditedQueryLoggingType, + lastEditedQueryMinOsqueryVersion, + }); + + try { + await queryAPI.update(queryId, updatedQuery); + renderFlash("success", "Query updated!"); + refetchStoredQuery(); // Required to compare recently saved query to a subsequent save to the query + } catch (updateError: any) { + console.error(updateError); + if (updateError.data.errors[0].reason.includes("Duplicate")) { + renderFlash("error", "A query with this name already exists."); + } else { + renderFlash( + "error", + "Something went wrong updating your query. Please try again." + ); + } + } + + setIsQueryUpdating(false); + setShowConfirmSaveChangesModal(false); // Closes conditionally opened modal when discarding previous results + + return false; + }; + + const onOsqueryTableSelect = (tableName: string) => { + setSelectedOsqueryTable(tableName); + }; + + const onCloseSchemaSidebar = () => { + setIsSidebarOpen(false); + }; + + const onOpenSchemaSidebar = () => { + setIsSidebarOpen(true); + }; + + const renderLiveQueryWarning = (): JSX.Element | null => { + if (isLiveQueryRunnable) { + return null; + } + + return ( +
+
+

+ Fleet is unable to run a live query. Refresh the page or log in + again. If this keeps happening please{" "} + +

+
+
+ ); + }; + + // Function instead of constant eliminates race condition + const backToQueriesPath = () => { + return queryId ? PATHS.QUERY(queryId) : PATHS.MANAGE_QUERIES; + }; + + const showSidebar = + isSidebarOpen && + (isGlobalAdmin || + isGlobalMaintainer || + isAnyTeamMaintainerOrTeamAdmin || + isObserverPlus || + isAnyTeamObserverPlus); + + return ( + <> + +
+
+
+ +
+ +
+
+
+ {showSidebar && ( + + + + )} + + ); +}; + +export default EditQueryPage; diff --git a/frontend/pages/queries/edit/_styles.scss b/frontend/pages/queries/edit/_styles.scss new file mode 100644 index 000000000000..944afd139670 --- /dev/null +++ b/frontend/pages/queries/edit/_styles.scss @@ -0,0 +1,86 @@ +.edit-query-page { + .help-text { + margin-top: $pad-small; + margin-bottom: $pad-large; + font-weight: $regular; + font-size: $xx-small; + color: $ui-fleet-black-75; + } + + .fleet-checkbox { + display: flex; + align-items: center; + } + + .form-field { + &--frequency { + margin-bottom: 0; + } + &--platform { + margin-bottom: 0; + margin-top: $pad-large; + } + } + + .advanced-options-toggle { + font-weight: $xbold; + } + + .observer-can-run-wrapper { + margin-bottom: 0; + font-weight: bold; + } + + .body-wrap { + min-width: 0; + } + + &__warning { + padding: $pad-medium; + font-size: $x-small; + color: $core-fleet-black; + background-color: #fff0b9; + border: 1px solid #f2c94c; + border-radius: $border-radius; + margin: 0; + margin-top: $pad-large; + + p { + margin: 0; + line-height: 20px; + } + } + + .ace_content { + min-height: 500px !important; + } + + &__count-spinner { + margin-right: $pad-small; + } + &__page-loading { + .loading-spinner { + margin: $pad-large 0 0; + } + } + &__page-error { + h4 { + margin: 0; + margin-top: 28px; + margin-left: -7px; + font-size: $small; + + img { + transform: scale(0.5); + vertical-align: middle; + position: relative; + top: -2px; + } + } + p { + margin: 0; + margin-top: $pad-medium; + font-size: $x-small; + } + } +} diff --git a/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx b/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx new file mode 100644 index 000000000000..e09da2109062 --- /dev/null +++ b/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/ConfirmSaveChangesModal.tsx @@ -0,0 +1,49 @@ +import React from "react"; + +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; + +const baseClass = "save-changes-modal"; + +export interface IConfirmSaveChangesModalProps { + isUpdating: boolean; + onSaveChanges: (evt: React.MouseEvent) => void; + onClose: () => void; + showChangedSQLCopy?: boolean; +} + +const ConfirmSaveChangesModal = ({ + isUpdating, + onSaveChanges, + onClose, + showChangedSQLCopy = false, +}: IConfirmSaveChangesModalProps) => { + const warningText = showChangedSQLCopy + ? "Changing this query's SQL will delete its previous results, since the existing report does not reflect the updated query." + : "The changes you are making to this query will delete its previous results."; + + return ( + +
+

{warningText}

+

You cannot undo this action.

+
+ + +
+
+
+ ); +}; + +export default ConfirmSaveChangesModal; diff --git a/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/index.ts b/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/index.ts new file mode 100644 index 000000000000..c8c31da396be --- /dev/null +++ b/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfirmSaveChangesModal"; diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.stories.tsx b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.stories.tsx new file mode 100644 index 000000000000..7a3b738ff1d5 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.stories.tsx @@ -0,0 +1,14 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import DiscardDataOption from "./DiscardDataOption"; + +const meta: Meta = { + title: "Components/DiscardDataOption", + component: DiscardDataOption, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = {}; diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tests.tsx b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tests.tsx new file mode 100644 index 000000000000..011e23d50201 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tests.tsx @@ -0,0 +1,86 @@ +import React from "react"; + +import { fireEvent, render, screen } from "@testing-library/react"; + +import DiscardDataOption from "./DiscardDataOption"; + +describe("DiscardDataOption component", () => { + const selectedLoggingType = "snapshot"; + const [discardData, setDiscardData] = [false, jest.fn()]; + + it("Renders normal help text when the global option is not disabled", () => { + render( + + ); + + expect(screen.getByText(/Discard data/)).toBeInTheDocument(); + expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument(); + }); + + it('Renders the "disabled" help text with tooltip when the global option is disabled', async () => { + render( + + ); + + expect(screen.getByText(/Discard data/)).toBeInTheDocument(); + expect(screen.getByText(/This setting is ignored/)).toBeInTheDocument(); + + await fireEvent.mouseOver(screen.getByText(/globally disabled/)); + + expect(screen.getByText(/A Fleet administrator/)).toBeInTheDocument(); + }); + + it('Restores normal help text when disabled and then "Edit anyway" is clicked', async () => { + render( + + ); + + // disabled + expect(screen.getByText(/Discard data/)).toBeInTheDocument(); + expect(screen.getByText(/This setting is ignored/)).toBeInTheDocument(); + + // enable + await fireEvent.click(screen.getByText(/Edit anyway/)); + + // normal text + expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument(); + }); + it('Renders the info banner when "Differential" logging option is selected', () => { + render( + + ); + + expect( + screen.getByText( + /setting is ignored when differential logging is enabled. This/ + ) + ).toBeInTheDocument(); + }); + it('Renders the info banner when "Differential (ignore removals)" logging option is selected', () => { + render( + + ); + expect( + screen.getByText( + /setting is ignored when differential logging is enabled. This/ + ) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tsx b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tsx new file mode 100644 index 000000000000..aef48c79b6ec --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tsx @@ -0,0 +1,103 @@ +import Checkbox from "components/forms/fields/Checkbox"; +import Icon from "components/Icon"; +import InfoBanner from "components/InfoBanner"; +import TooltipWrapper from "components/TooltipWrapper"; +import { QueryLoggingOption } from "interfaces/schedulable_query"; +import React, { useState } from "react"; +import { Link } from "react-router"; + +const baseClass = "discard-data-option"; + +interface IDiscardDataOptionProps { + queryReportsDisabled: boolean; + selectedLoggingType: QueryLoggingOption; + discardData: boolean; + setDiscardData: (value: boolean) => void; + breakHelpText?: boolean; +} + +const DiscardDataOption = ({ + queryReportsDisabled, + selectedLoggingType, + discardData, + setDiscardData, + breakHelpText = false, +}: IDiscardDataOptionProps) => { + const [forceEditDiscardData, setForceEditDiscardData] = useState(false); + const disable = queryReportsDisabled && !forceEditDiscardData; + + const renderHelpText = () => ( +
+ {disable ? ( + <> + This setting is ignored because query reports in Fleet have been{" "} + \ + Organization settings > Advanced options > Disable query reports." + } + position="bottom" + > + {"globally disabled."} + {" "} + { + e.preventDefault(); + setForceEditDiscardData(true); + }} + className={`${baseClass}__edit-anyway`} + > + <> + Edit anyway + + + + + ) : ( + <> + The most recent results for each host will not be available in Fleet. + {breakHelpText ?
: " "} + Data will still be sent to your log destination if + automations + {" "} + are on. + + )} +
+ ); + return ( +
+ {["differential", "differential_ignore_removals"].includes( + selectedLoggingType + ) && ( + + <> + The Discard data setting is ignored when differential logging + is enabled. This
+ query's results will not be saved in Fleet. + +
+ )} + + Discard data + + {renderHelpText()} +
+ ); +}; + +export default DiscardDataOption; diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/_styles.scss b/frontend/pages/queries/edit/components/DiscardDataOption/_styles.scss new file mode 100644 index 000000000000..c938b5806993 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/_styles.scss @@ -0,0 +1,20 @@ +.discard-data-option { + .info-banner { + margin-bottom: 1.5rem; + &__info { + line-height: 21px; + } + } + + &__disabled-discard-data-checkbox { + @include disabled; + } + + &__edit-anyway { + display: inline-flex; + align-items: center; + cursor: pointer; + font-weight: inherit; + font-size: inherit; + } +} diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/index.ts b/frontend/pages/queries/edit/components/DiscardDataOption/index.ts new file mode 100644 index 000000000000..71d3111a3b15 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/index.ts @@ -0,0 +1 @@ +export { default } from "./DiscardDataOption"; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx similarity index 92% rename from frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx rename to frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx index 87bc29c8fd5b..a203afbf966e 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx @@ -5,7 +5,7 @@ import { createCustomRenderer } from "test/test-utils"; import createMockQuery from "__mocks__/queryMock"; import createMockUser from "__mocks__/userMock"; -import QueryForm from "./QueryForm"; +import EditQueryForm from "./EditQueryForm"; const mockQuery = createMockQuery(); const mockRouter = { @@ -20,7 +20,7 @@ const mockRouter = { createPath: jest.fn(), }; -describe("QueryForm - component", () => { +describe("EditQueryForm - component", () => { it("disables save button for missing query name", async () => { const render = createCustomRenderer({ context: { @@ -56,7 +56,7 @@ describe("QueryForm - component", () => { }); render( - { isQueryUpdating={false} saveQuery={jest.fn()} onOsqueryTableSelect={jest.fn()} - goToSelectTargets={jest.fn()} onUpdate={jest.fn()} onOpenSchemaSidebar={jest.fn()} renderLiveQueryWarning={jest.fn()} backendValidators={{}} + showConfirmSaveChangesModal={false} + setShowConfirmSaveChangesModal={jest.fn()} /> ); diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx similarity index 86% rename from frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx rename to frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index 452e06d4ee85..ce2a027e3225 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -15,7 +15,11 @@ import PATHS from "router/paths"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; -import { addGravatarUrlToResource, secondsToDhms } from "utilities/helpers"; +import { + addGravatarUrlToResource, + secondsToDhms, + TAGGED_TEMPLATES, +} from "utilities/helpers"; import { FREQUENCY_DROPDOWN_OPTIONS, SCHEDULE_PLATFORM_DROPDOWN_OPTIONS, @@ -48,10 +52,12 @@ import Spinner from "components/Spinner"; import Icon from "components/Icon/Icon"; import AutoSizeInputField from "components/forms/fields/AutoSizeInputField"; import SaveQueryModal from "../SaveQueryModal"; +import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal"; +import DiscardDataOption from "../DiscardDataOption"; -const baseClass = "query-form"; +const baseClass = "edit-query-form"; -interface IQueryFormProps { +interface IEditQueryFormProps { router: InjectedRouter; queryIdForEdit: number | null; apiTeamIdForQuery?: number; @@ -63,11 +69,14 @@ interface IQueryFormProps { isQueryUpdating: boolean; saveQuery: (formData: ICreateQueryRequestBody) => void; onOsqueryTableSelect: (tableName: string) => void; - goToSelectTargets: () => void; onUpdate: (formData: ICreateQueryRequestBody) => void; onOpenSchemaSidebar: () => void; renderLiveQueryWarning: () => JSX.Element | null; backendValidators: { [key: string]: string }; + hostId?: number; + queryReportsDisabled?: boolean; + showConfirmSaveChangesModal: boolean; + setShowConfirmSaveChangesModal: (bool: boolean) => void; } const validateQuerySQL = (query: string) => { @@ -98,7 +107,7 @@ const customFrequencyOptions = (frequency: number) => { return FREQUENCY_DROPDOWN_OPTIONS; }; -const QueryForm = ({ +const EditQueryForm = ({ router, queryIdForEdit, apiTeamIdForQuery, @@ -110,12 +119,15 @@ const QueryForm = ({ isQueryUpdating, saveQuery, onOsqueryTableSelect, - goToSelectTargets, onUpdate, onOpenSchemaSidebar, renderLiveQueryWarning, backendValidators, -}: IQueryFormProps): JSX.Element => { + hostId, + queryReportsDisabled, + showConfirmSaveChangesModal, + setShowConfirmSaveChangesModal, +}: IEditQueryFormProps): JSX.Element => { // Note: The QueryContext values should always be used for any mutable query data such as query name // The storedQuery prop should only be used to access immutable metadata such as author id const { @@ -128,6 +140,7 @@ const QueryForm = ({ lastEditedQueryPlatforms, lastEditedQueryMinOsqueryVersion, lastEditedQueryLoggingType, + lastEditedQueryDiscardData, setLastEditedQueryName, setLastEditedQueryDescription, setLastEditedQueryBody, @@ -136,6 +149,7 @@ const QueryForm = ({ setLastEditedQueryPlatforms, setLastEditedQueryMinOsqueryVersion, setLastEditedQueryLoggingType, + setLastEditedQueryDiscardData, } = useContext(QueryContext); const { @@ -169,9 +183,7 @@ const QueryForm = ({ const { setCompatiblePlatforms } = platformCompatibility; const debounceSQL = useDebouncedCallback((sql: string) => { - let valid = true; - const { valid: isValidated, errors: newErrors } = validateQuerySQL(sql); - valid = isValidated; + const { errors: newErrors } = validateQuerySQL(sql); setErrors({ ...newErrors, @@ -194,17 +206,14 @@ const QueryForm = ({ } }, [lastEditedQueryFrequency, isInitialFrequency]); - const hasTeamMaintainerPermissions = savedQueryMode - ? isAnyTeamMaintainerOrTeamAdmin && - storedQuery && - currentUser && - storedQuery.author_id === currentUser.id - : isAnyTeamMaintainerOrTeamAdmin; - const toggleSaveQueryModal = () => { setShowSaveQueryModal(!showSaveQueryModal); }; + const toggleConfirmSaveChangesModal = () => { + setShowConfirmSaveChangesModal(!showConfirmSaveChangesModal); + }; + const onLoad = (editor: IAceEditor) => { editor.setOptions({ enableLinking: true, @@ -398,6 +407,7 @@ const QueryForm = ({ platform: lastEditedQueryPlatforms, min_osquery_version: lastEditedQueryMinOsqueryVersion, logging: lastEditedQueryLoggingType, + discard_data: lastEditedQueryDiscardData, }); } } @@ -575,7 +585,12 @@ const QueryForm = ({ @@ -586,6 +601,28 @@ const QueryForm = ({ const hasSavePermissions = isGlobalAdmin || isGlobalMaintainer; + const currentlySavingQueryResults = + storedQuery && + !storedQuery.discard_data && + !["differential", "differential_ignore_removals"].includes( + storedQuery.logging + ); + const changedSQL = storedQuery && lastEditedQueryBody !== storedQuery.query; + const changedLoggingToDifferential = [ + "differential", + "differential_ignore_removals", + ].includes(lastEditedQueryLoggingType); + + const enabledDiscardData = + storedQuery && lastEditedQueryDiscardData && !storedQuery.discard_data; + + const confirmChanges = + currentlySavingQueryResults && + (changedSQL || changedLoggingToDifferential || enabledDiscardData); + + const showChangedSQLCopy = + changedSQL && !changedLoggingToDifferential && !enabledDiscardData; + // Global admin, any maintainer, any observer+ on new query const renderEditableQueryForm = () => { // Save disabled for team maintainer/admins viewing global queries @@ -617,7 +654,9 @@ const QueryForm = ({ onLoad={onLoad} wrapperClassName={`${baseClass}__text-editor-wrapper`} onChange={onChangeQuery} - handleSubmit={promptSaveQuery} + handleSubmit={ + confirmChanges ? toggleConfirmSaveChangesModal : promptSaveQuery + } wrapEnabled focus={!savedQueryMode} /> @@ -634,10 +673,11 @@ const QueryForm = ({ placeholder={"Every day"} value={lastEditedQueryFrequency} label={"Frequency"} - wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} + wrapperClassName={`${baseClass}__form-field form-field--frequency`} /> - If automations are on, this is how often your query collects - data. +
+ This is how often your query collects data. +
setLastEditedQueryObserverCanRun(value) } - wrapperClassName={`${baseClass}__query-observer-can-run-wrapper`} + wrapperClassName={"observer-can-run-wrapper"} > Observers can run -

+

Users with the observer role will be able to run this query on hosts where they have access. -

+
+
+ By default, your query collects data on all compatible + platforms. +
+ {queryReportsDisabled !== undefined && ( + + )}
)}
@@ -710,7 +762,7 @@ const QueryForm = ({ Save as new )} -
+
{ + router.push( + PATHS.LIVE_QUERY(queryIdForEdit) + + TAGGED_TEMPLATES.queryByHostRoute(hostId) + ); + }} > Live query @@ -763,6 +824,15 @@ const QueryForm = ({ toggleSaveQueryModal={toggleSaveQueryModal} backendValidators={backendValidators} isLoading={isQuerySaving} + queryReportsDisabled={queryReportsDisabled} + /> + )} + {showConfirmSaveChangesModal && ( + )} @@ -790,4 +860,4 @@ const QueryForm = ({ return renderEditableQueryForm(); }; -export default QueryForm; +export default EditQueryForm; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss b/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss similarity index 86% rename from frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss rename to frontend/pages/queries/edit/components/EditQueryForm/_styles.scss index 5a9ab0d49f35..f2ee6ab7881b 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss +++ b/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss @@ -1,13 +1,8 @@ -.query-form { +.edit-query-form { &__wrapper { position: relative; font-size: $x-small; - .query-page__warning { - margin: 0; - margin-top: $pad-large; - } - .form-field--input { margin: 0; } @@ -51,7 +46,7 @@ .query-name-wrapper { display: flex; - &:not(.query-form--editing) { + &:not(.edit-query-form--editing) { textarea:hover { cursor: pointer; color: $core-vibrant-blue; @@ -62,7 +57,7 @@ top: 13px; margin-left: 0; } - .query-form__query-name, + .edit-query-form__query-name, .input-sizer::after { font-size: $large; } @@ -75,7 +70,7 @@ .query-description-wrapper { display: flex; padding-top: $pad-small; - &:not(.query-form--editing) { + &:not(.edit-query-form--editing) { textarea:hover { cursor: pointer; color: $core-vibrant-blue; @@ -166,26 +161,6 @@ } } - &__advanced-options { - margin-top: $pad-medium; - } - - &__query-observer-can-run-wrapper { - margin: 0; - margin-top: $pad-large; - font-weight: $bold !important; // override checkbox default - - & + p { - margin: 0; - margin-top: $pad-small; - } - - .fleet-checkbox { - display: flex; - align-items: center; - } - } - &__button-wrap { margin: 0; margin-top: $pad-large; diff --git a/frontend/pages/queries/edit/components/EditQueryForm/index.ts b/frontend/pages/queries/edit/components/EditQueryForm/index.ts new file mode 100644 index 000000000000..a657cb4f468b --- /dev/null +++ b/frontend/pages/queries/edit/components/EditQueryForm/index.ts @@ -0,0 +1 @@ +export { default } from "./EditQueryForm"; diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx rename to frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResultsTableConfig.tsx similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx rename to frontend/pages/queries/edit/components/QueryResults/QueryResultsTableConfig.tsx diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss b/frontend/pages/queries/edit/components/QueryResults/_styles.scss similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss rename to frontend/pages/queries/edit/components/QueryResults/_styles.scss diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/index.ts b/frontend/pages/queries/edit/components/QueryResults/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/index.ts rename to frontend/pages/queries/edit/components/QueryResults/index.ts diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx similarity index 51% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx rename to frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx index f74a45f66c6f..f7b3a341ee24 100644 --- a/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx +++ b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx @@ -23,6 +23,7 @@ import { ISchedulableQuery, QueryLoggingOption, } from "interfaces/schedulable_query"; +import DiscardDataOption from "../DiscardDataOption"; const baseClass = "save-query-modal"; export interface ISaveQueryModalProps { @@ -33,6 +34,7 @@ export interface ISaveQueryModalProps { toggleSaveQueryModal: () => void; backendValidators: { [key: string]: string }; existingQuery?: ISchedulableQuery; + queryReportsDisabled?: boolean; } const validateQueryName = (name: string) => { @@ -54,6 +56,7 @@ const SaveQueryModal = ({ toggleSaveQueryModal, backendValidators, existingQuery, + queryReportsDisabled, }: ISaveQueryModalProps): JSX.Element => { const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -73,6 +76,7 @@ const SaveQueryModal = ({ setSelectedLoggingType, ] = useState(existingQuery?.logging ?? "snapshot"); const [observerCanRun, setObserverCanRun] = useState(false); + const [discardData, setDiscardData] = useState(false); const [errors, setErrors] = useState<{ [key: string]: string }>( backendValidators ); @@ -108,6 +112,7 @@ const SaveQueryModal = ({ description, interval: selectedFrequency, observer_can_run: observerCanRun, + discard_data: discardData, platform: selectedPlatformOptions, min_osquery_version: selectedMinOsqueryVersionOptions, logging: selectedLoggingType, @@ -141,116 +146,122 @@ const SaveQueryModal = ({ return ( - <> -
+ setName(value)} + value={name} + error={errors.name} + inputClassName={`${baseClass}__name`} + label="Name" + placeholder="What is your query called?" + autofocus + ignore1password + /> + setDescription(value)} + value={description} + inputClassName={`${baseClass}__description`} + label="Description" + type="textarea" + placeholder="What information does your query reveal? (optional)" + /> + { + setSelectedFrequency(value); + }} + placeholder={"Every hour"} + value={selectedFrequency} + label="Frequency" + wrapperClassName={`${baseClass}__form-field form-field--frequency`} + /> +
+ This is how often your query collects data. +
+ - setName(value)} - value={name} - error={errors.name} - inputClassName={`${baseClass}__name`} - label="Name" - placeholder="What is your query called?" - autofocus - ignore1password - /> - setDescription(value)} - value={description} - inputClassName={`${baseClass}__description`} - label="Description" - type="textarea" - placeholder="What information does your query reveal? (optional)" - /> - { - setSelectedFrequency(value); - }} - placeholder={"Every hour"} - value={selectedFrequency} - label="Frequency" - wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} - /> -

- If automations are on, this is how often your query collects data. -

- - Observers can run - -

- Users with the Observer role will be able to run this query as a - live query. -

- - {showAdvancedOptions && ( - <> - -

- If automations are turned on, your query collects data on - compatible platforms. -
- If you want more control, override platforms. -

- +
+ Users with the Observer role will be able to run this query as a live + query. +
+ + {showAdvancedOptions && ( + <> + +
+ By default, your query collects data on all compatible platforms. +
+ + + {queryReportsDisabled !== undefined && ( + - - - )} -
- - -
- - + )} + + )} +
+ + +
+
); }; diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts b/frontend/pages/queries/edit/components/SaveQueryModal/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts rename to frontend/pages/queries/edit/components/SaveQueryModal/index.ts diff --git a/frontend/pages/queries/edit/index.ts b/frontend/pages/queries/edit/index.ts new file mode 100644 index 000000000000..29c0d100ace2 --- /dev/null +++ b/frontend/pages/queries/edit/index.ts @@ -0,0 +1 @@ +export { default } from "./EditQueryPage"; diff --git a/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx new file mode 100644 index 000000000000..127f3b17c227 --- /dev/null +++ b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx @@ -0,0 +1,219 @@ +import React, { useState, useEffect, useContext, useCallback } from "react"; +import { useQuery } from "react-query"; +import { useErrorHandler } from "react-error-boundary"; +import { InjectedRouter, Params } from "react-router/lib/Router"; +import PATHS from "router/paths"; + +import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; +import { LIVE_QUERY_STEPS, DEFAULT_QUERY } from "utilities/constants"; +import queryAPI from "services/entities/queries"; +import hostAPI from "services/entities/hosts"; +import statusAPI from "services/entities/status"; +import { IHost, IHostResponse } from "interfaces/host"; +import { ILabel } from "interfaces/label"; +import { ITeam } from "interfaces/team"; +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; + +import MainContent from "components/MainContent"; +import SelectTargets from "components/LiveQuery/SelectTargets"; + +import RunQuery from "pages/queries/live/screens/RunQuery"; +import useTeamIdParam from "hooks/useTeamIdParam"; + +interface IRunQueryPageProps { + router: InjectedRouter; + params: Params; + location: { + pathname: string; + query: { host_ids: string; team_id?: string }; + search: string; + }; +} + +const baseClass = "run-query-page"; + +const RunQueryPage = ({ + router, + params: { id: paramsQueryId }, + location, +}: IRunQueryPageProps): JSX.Element => { + const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + const { + currentTeamName: teamNameForQuery, + teamIdForApi: apiTeamIdForQuery, + } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); + + const handlePageError = useErrorHandler(); + const { + isGlobalAdmin, + isGlobalMaintainer, + isAnyTeamMaintainerOrTeamAdmin, + isObserverPlus, + isAnyTeamObserverPlus, + } = useContext(AppContext); + const { + selectedQueryTargets, + setSelectedQueryTargets, + selectedQueryTargetsByType, + setSelectedQueryTargetsByType, + setLastEditedQueryId, + setLastEditedQueryName, + setLastEditedQueryDescription, + setLastEditedQueryBody, + setLastEditedQueryObserverCanRun, + setLastEditedQueryFrequency, + setLastEditedQueryLoggingType, + setLastEditedQueryMinOsqueryVersion, + setLastEditedQueryPlatforms, + } = useContext(QueryContext); + + const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); + const [step, setStep] = useState(LIVE_QUERY_STEPS[1]); + const [targetedHosts, setTargetedHosts] = useState( + selectedQueryTargetsByType.hosts + ); + const [targetedLabels, setTargetedLabels] = useState( + selectedQueryTargetsByType.labels + ); + const [targetedTeams, setTargetedTeams] = useState( + selectedQueryTargetsByType.teams + ); + const [targetsTotalCount, setTargetsTotalCount] = useState(0); + const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true); + + // disabled on page load so we can control the number of renders + // else it will re-populate the context on occasion + const { data: storedQuery } = useQuery< + IGetQueryResponse, + Error, + ISchedulableQuery + >(["query", queryId], () => queryAPI.load(queryId as number), { + enabled: !!queryId, + refetchOnWindowFocus: false, + select: (data) => data.query, + onSuccess: (returnedQuery) => { + setLastEditedQueryId(returnedQuery.id); + setLastEditedQueryName(returnedQuery.name); + setLastEditedQueryDescription(returnedQuery.description); + setLastEditedQueryBody(returnedQuery.query); + setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); + setLastEditedQueryFrequency(returnedQuery.interval); + setLastEditedQueryPlatforms(returnedQuery.platform); + setLastEditedQueryLoggingType(returnedQuery.logging); + setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); + }, + onError: (error) => handlePageError(error), + }); + + useQuery( + "hostFromURL", + () => + hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), + { + enabled: !!location.query.host_ids && !queryParamHostsAdded, + select: (data: IHostResponse) => data.host, + onSuccess: (host) => { + setTargetedHosts((prevHosts) => + prevHosts.filter((h) => h.id !== host.id).concat(host) + ); + const targets = selectedQueryTargets; + host.target_type = "hosts"; + targets.push(host); + setSelectedQueryTargets([...targets]); + if (!queryParamHostsAdded) { + setQueryParamHostsAdded(true); + } + router.replace(location.pathname); + }, + } + ); + + const detectIsFleetQueryRunnable = () => { + statusAPI.live_query().catch(() => { + setIsLiveQueryRunnable(false); + }); + }; + + useEffect(() => { + detectIsFleetQueryRunnable(); + }, [queryId]); + + useEffect(() => { + setSelectedQueryTargetsByType({ + hosts: targetedHosts, + labels: targetedLabels, + teams: targetedTeams, + }); + }, [targetedLabels, targetedHosts, targetedTeams]); + + console.log( + "LiveQueryPage.tsx: selectedQueryTargetsByType", + selectedQueryTargetsByType + ); + + // Updates title that shows up on browser tabs + useEffect(() => { + // e.g., Run live query | Discover TLS certificates | Fleet for osquery + document.title = `Run live query | ${storedQuery?.name} | Fleet for osquery`; + }, [location.pathname, storedQuery?.name]); + + const goToQueryEditor = useCallback( + () => + queryId + ? router.push(PATHS.EDIT_QUERY(queryId)) + : router.push(PATHS.NEW_QUERY()), + [] + ); + + const renderScreen = () => { + const step1Props = { + baseClass, + queryId, + selectedTargets: selectedQueryTargets, + targetedHosts, + targetedLabels, + targetedTeams, + targetsTotalCount, + goToQueryEditor, + goToRunQuery: () => setStep(LIVE_QUERY_STEPS[2]), + setSelectedTargets: setSelectedQueryTargets, + setTargetedHosts, + setTargetedLabels, + setTargetedTeams, + setTargetsTotalCount, + }; + + const step2Props = { + queryId, + selectedTargets: selectedQueryTargets, + storedQuery, + setSelectedTargets: setSelectedQueryTargets, + goToQueryEditor, + targetsTotalCount, + }; + + switch (step) { + case LIVE_QUERY_STEPS[2]: + return ; + default: + return ; + } + }; + + return ( + +
{renderScreen()}
+
+ ); +}; + +export default RunQueryPage; diff --git a/frontend/pages/queries/QueryPage/_styles.scss b/frontend/pages/queries/live/LiveQueryPage/_styles.scss similarity index 69% rename from frontend/pages/queries/QueryPage/_styles.scss rename to frontend/pages/queries/live/LiveQueryPage/_styles.scss index 8e1198b7f82b..1ec2385d0972 100644 --- a/frontend/pages/queries/QueryPage/_styles.scss +++ b/frontend/pages/queries/live/LiveQueryPage/_styles.scss @@ -1,4 +1,4 @@ -.query-page { +.run-query-page { .body-wrap { min-width: 0; } @@ -9,61 +9,6 @@ min-height: 400px; } - &__warning { - padding: $pad-medium; - font-size: $x-small; - color: $core-fleet-black; - background-color: #fff0b9; - border: 1px solid #f2c94c; - border-radius: $border-radius; - - p { - margin: 0; - line-height: 20px; - } - } - - &__observer-query-view { - width: 90%; - max-width: 1060px; - margin: 0 auto; - color: $core-fleet-black; - - h1 { - font-size: $medium; - } - p { - font-size: $x-small; - } - } - - &__observer-query-details { - padding: 0 2rem; - - h1 { - margin: $pad-large 0; - font-size: $large; - } - - p { - margin-bottom: $pad-small; - } - - .sql-button { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - } - } - - &__query-preview { - margin-top: 15px; - - .fleet-ace__label { - display: none; - } - } - .ace_content { min-height: 500px !important; } @@ -172,10 +117,4 @@ font-size: $x-small; } } - - .targets-input { - .input-icon-field__icon { - top: 34px; // Override styling to include label header - } - } } diff --git a/frontend/pages/queries/live/LiveQueryPage/index.ts b/frontend/pages/queries/live/LiveQueryPage/index.ts new file mode 100644 index 000000000000..354c0445d108 --- /dev/null +++ b/frontend/pages/queries/live/LiveQueryPage/index.ts @@ -0,0 +1 @@ +export { default } from "./LiveQueryPage"; diff --git a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx b/frontend/pages/queries/live/screens/RunQuery.tsx similarity index 98% rename from frontend/pages/queries/QueryPage/screens/RunQuery.tsx rename to frontend/pages/queries/live/screens/RunQuery.tsx index dbceec8069d1..7715899b3439 100644 --- a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx +++ b/frontend/pages/queries/live/screens/RunQuery.tsx @@ -15,7 +15,7 @@ import { ICampaign, ICampaignState } from "interfaces/campaign"; import { IQuery } from "interfaces/query"; import { ITarget } from "interfaces/target"; -import QueryResults from "../components/QueryResults"; +import QueryResults from "../../edit/components/QueryResults"; interface IRunQueryProps { storedQuery: IQuery | undefined; diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss index 5bb83b84f0a8..2f6297388a27 100644 --- a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss +++ b/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss @@ -4,7 +4,7 @@ background-color: $ui-off-white; color: $core-fleet-blue; border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; padding: 7px $pad-medium; margin: $pad-large 0 0 44px; } diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index f314b5ecc897..5410c1ac99fb 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -36,7 +36,9 @@ import ManagePoliciesPage from "pages/policies/ManagePoliciesPage"; import NoAccessPage from "pages/NoAccessPage"; import PackComposerPage from "pages/packs/PackComposerPage"; import PolicyPage from "pages/policies/PolicyPage"; -import QueryPage from "pages/queries/QueryPage"; +import QueryDetailsPage from "pages/queries/details/QueryDetailsPage"; +import LiveQueryPage from "pages/queries/live/LiveQueryPage"; +import EditQueryPage from "pages/queries/edit/EditQueryPage"; import RegistrationPage from "pages/RegistrationPage"; import ResetPasswordPage from "pages/ResetPasswordPage"; import MDMAppleSSOPage from "pages/MDMAppleSSOPage"; @@ -220,9 +222,16 @@ const routes = ( - + + + + + + + + + - diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 834403705e5d..ce1b2b582f62 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -53,6 +53,16 @@ export default { 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): string => { + return `${URL_PREFIX}/queries/${queryId || "new"}/live${ + teamId ? `?team_id=${teamId}` : "" + }`; + }, + QUERY: (queryId: number, teamId?: number): string => { return `${URL_PREFIX}/queries/${queryId}${ teamId ? `?team_id=${teamId}` : "" }`; diff --git a/frontend/services/entities/host_count.ts b/frontend/services/entities/host_count.ts index 9e4bba000b85..5511dcfc10f9 100644 --- a/frontend/services/entities/host_count.ts +++ b/frontend/services/entities/host_count.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import sendRequest from "services"; import endpoints from "utilities/endpoints"; -import { FileVaultProfileStatus, BootstrapPackageStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus, BootstrapPackageStatus } from "interfaces/mdm"; import { HostStatus } from "interfaces/host"; import { buildQueryStringFromParams, @@ -43,7 +43,7 @@ export interface IHostCountLoadOptions { osId?: number; osName?: string; osVersion?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; } diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index b8abf7061bd4..e4d95353d567 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -11,7 +11,7 @@ import { import { SelectedPlatform } from "interfaces/platform"; import { ISoftware } from "interfaces/software"; import { - FileVaultProfileStatus, + DiskEncryptionStatus, BootstrapPackageStatus, IMdmSolution, } from "interfaces/mdm"; @@ -29,6 +29,11 @@ export interface ILoadHostsResponse { mobile_device_management_solution: IMdmSolution; } +// the source of truth for the filter option names. +// there are used on many other pages but we define them here. +// TODO: add other filter options here. +export const DISK_ENCRYPTION_QUERY_PARAM_NAME = "os_settings_disk_encryption"; + export interface ILoadHostsQueryKey extends ILoadHostsOptions { scope: "hosts"; } @@ -57,7 +62,7 @@ export interface ILoadHostsOptions { device_mapping?: boolean; columns?: string; visibleColumns?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; } @@ -83,7 +88,7 @@ export interface IExportHostsOptions { device_mapping?: boolean; columns?: string; visibleColumns?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; } export interface IActionByFilter { diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index 55416203dc5e..41301f3d3bb2 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -1,19 +1,61 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { FileVaultProfileStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus, MdmProfileStatus } from "interfaces/mdm"; import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; -export type IFileVaultSummaryResponse = Record; - export interface IEulaMetadataResponse { name: string; token: string; created_at: string; } -export default { +export type ProfileStatusSummaryResponse = Record; + +export interface IDiskEncryptionStatusAggregate { + macos: number; + windows: number; +} + +export type IDiskEncryptionSummaryResponse = Record< + DiskEncryptionStatus, + IDiskEncryptionStatusAggregate +>; + +// This function combines the profile status summary and the disk encryption summary +// to generate the aggregate profile status summary. We are doing this as a temporary +// solution until we have the API that will return the aggregate profile status summary +// from one call. +// TODO: API INTEGRATION: remove when API is implemented that returns windows +// data in the aggregate profile status summary. +const generateCombinedProfileStatusSummary = ( + profileStatuses: ProfileStatusSummaryResponse, + diskEncryptionSummary: IDiskEncryptionSummaryResponse +): ProfileStatusSummaryResponse => { + const { verified, verifying, failed, pending } = profileStatuses; + const { + verified: verifiedDiskEncryption, + verifying: verifyingDiskEncryption, + failed: failedDiskEncryption, + action_required: actionRequiredDiskEncryption, + enforcing: enforcingDiskEncryption, + removing_enforcement: removingEnforcementDiskEncryption, + } = diskEncryptionSummary; + + return { + verified: verified + verifiedDiskEncryption.windows, + verifying: verifying + verifyingDiskEncryption.windows, + failed: failed + failedDiskEncryption.windows, + pending: + pending + + actionRequiredDiskEncryption.windows + + enforcingDiskEncryption.windows + + removingEnforcementDiskEncryption.windows, + }; +}; + +const mdmService = { downloadDeviceUserEnrollmentProfile: (token: string) => { const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints; return sendRequest("GET", DEVICE_USER_MDM_ENROLLMENT_PROFILE(token)); @@ -72,24 +114,51 @@ export default { return sendRequest("DELETE", MDM_PROFILE(profileId)); }, - getAggregateProfileStatuses: (teamId = APP_CONTEXT_NO_TEAM_ID) => { + // TODO: API INTEGRATION: we need to rework this when we create API call that + // will return the aggregate statuses for windows included in the response. + // Currently to get windows data included we will need to make a separate call. + // We will likely change this to go back to single "getProfileStatusSummary" API call. + getAggregateProfileStatuses: async ( + teamId = APP_CONTEXT_NO_TEAM_ID, + // TODO: WINDOWS FEATURE FLAG: remove when we windows feature is released. + includeWindows: boolean + ) => { + // if we are not including windows we can just call the existing profile summary API + if (!includeWindows) { + return mdmService.getProfileStatusSummary(teamId); + } + + // otherwise we have to make two calls and combine the results. + return mdmService + .getAggregateProfileStatusesWithWindows(teamId) + .then((res) => generateCombinedProfileStatusSummary(...res)); + }, + + getAggregateProfileStatusesWithWindows: async (teamId: number) => { + return Promise.all([ + mdmService.getProfileStatusSummary(teamId), + mdmService.getDiskEncryptionSummary(teamId), + ]); + }, + + getProfileStatusSummary: (teamId = APP_CONTEXT_NO_TEAM_ID) => { const path = `${ endpoints.MDM_PROFILES_AGGREGATE_STATUSES }?${buildQueryStringFromParams({ team_id: teamId })}`; - return sendRequest("GET", path); }, - getDiskEncryptionAggregate: (teamId?: number) => { - let { MDM_APPLE_DISK_ENCRYPTION_AGGREGATE: path } = endpoints; + getDiskEncryptionSummary: (teamId?: number) => { + let { MDM_DISK_ENCRYPTION_SUMMARY: path } = endpoints; if (teamId) { path = `${path}?${buildQueryStringFromParams({ team_id: teamId })}`; } - return sendRequest("GET", path); }, + // TODO: API INTEGRATION: change when API is implemented that works for windows + // disk encryption too. updateAppleMdmSettings: (enableDiskEncryption: boolean, teamId?: number) => { const { MDM_UPDATE_APPLE_SETTINGS: teamsEndpoint, @@ -98,7 +167,9 @@ export default { if (teamId === 0) { return sendRequest("PATCH", noTeamsEndpoint, { mdm: { + // TODO: API INTEGRATION: remove macos_settings when API change is merged in. macos_settings: { enable_disk_encryption: enableDiskEncryption }, + // enable_disk_encryption: enableDiskEncryption, }, }); } @@ -179,3 +250,5 @@ export default { }); }, }; + +export default mdmService; diff --git a/frontend/services/entities/queries.ts b/frontend/services/entities/queries.ts index 89765f648172..355429d3169e 100644 --- a/frontend/services/entities/queries.ts +++ b/frontend/services/entities/queries.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import sendRequest, { getError } from "services"; import endpoints from "utilities/endpoints"; -import { ISelectedTargets } from "interfaces/target"; +import { ISelectedTargetsForApi } from "interfaces/target"; import { AxiosResponse } from "axios"; import { ICreateQueryRequestBody, @@ -52,12 +52,12 @@ export default { }: { query: string; queryId: number | null; - selected: ISelectedTargets; + selected: ISelectedTargetsForApi; }) => { - const { RUN_QUERY } = endpoints; + const { LIVE_QUERY } = endpoints; try { - const { campaign } = await sendRequest("POST", RUN_QUERY, { + const { campaign } = await sendRequest("POST", LIVE_QUERY, { query, query_id: queryId, selected, diff --git a/frontend/services/entities/query_report.ts b/frontend/services/entities/query_report.ts new file mode 100644 index 000000000000..9dbf13834efc --- /dev/null +++ b/frontend/services/entities/query_report.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +// import sendRequest from "services"; +import endpoints from "utilities/endpoints"; + +import { buildQueryStringFromParams } from "utilities/url"; + +// Mock API requests to be used in developing FE for #7766 in parallel with BE development +import { sendRequest } from "services/mock_service/service/service"; + +export interface ISortOption { + key: string; + direction: string; +} + +export interface ILoadQueryReportOptions { + id: number; + sortBy: ISortOption[]; +} + +const getSortParams = (sortOptions?: ISortOption[]) => { + if (sortOptions === undefined || sortOptions.length === 0) { + return {}; + } + + const sortItem = sortOptions[0]; + return { + order_key: sortItem.key, + order_direction: sortItem.direction, + }; +}; + +export default { + load: ({ id, sortBy }: ILoadQueryReportOptions) => { + const sortParams = getSortParams(sortBy); + + const { QUERIES } = endpoints; + + const queryParams = { + order_key: sortParams.order_key, + order_direction: sortParams.order_direction, + }; + + const queryString = buildQueryStringFromParams(queryParams); + + // const endpoint = `${QUERIES}/${id}/report`; + const endpoint = `${QUERIES}/113/report`; + const path = `${endpoint}?${queryString}`; + return sendRequest("GET", path); + }, +}; diff --git a/frontend/services/entities/targets.ts b/frontend/services/entities/targets.ts index 0e00bc286fbe..400733da22a7 100644 --- a/frontend/services/entities/targets.ts +++ b/frontend/services/entities/targets.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import sendRequest from "services"; import { IHost } from "interfaces/host"; -import { ISelectedTargets, ITargetsAPIResponse } from "interfaces/target"; +import { ISelectedTargetsForApi, ITargetsAPIResponse } from "interfaces/target"; import endpoints from "utilities/endpoints"; import appendTargetTypeToTargets from "utilities/append_target_type_to_targets"; interface ITargetsProps { query?: string; queryId?: number | null; - selected: ISelectedTargets; + selected: ISelectedTargetsForApi; } const defaultSelected = { @@ -29,7 +29,7 @@ export interface ITargetsSearchResponse { export interface ITargetsCountParams { query_id?: number | null; - selected: ISelectedTargets | null; + selected: ISelectedTargetsForApi | null; } export interface ITargetsCountResponse { diff --git a/frontend/services/mock_service/mocks/config.ts b/frontend/services/mock_service/mocks/config.ts index 50ebcbf1f94b..5fd108c99462 100644 --- a/frontend/services/mock_service/mocks/config.ts +++ b/frontend/services/mock_service/mocks/config.ts @@ -33,6 +33,8 @@ const REQUEST_RESPONSE_MAPPINGS: IResponses = { "queries/7": RESPONSES.globalQuery6, "queries/8": RESPONSES.teamQuery2, "queries?team_id=13": RESPONSES.teamQueries, + "queries/113/report?order_key=host_name&order_direction=asc": + RESPONSES.queryReport, }, POST: { // request body is ISelectedTargets diff --git a/frontend/services/mock_service/mocks/responses.ts b/frontend/services/mock_service/mocks/responses.ts index d20d275f8f2c..2a2bf579c32c 100644 --- a/frontend/services/mock_service/mocks/responses.ts +++ b/frontend/services/mock_service/mocks/responses.ts @@ -598,6 +598,9987 @@ const teamQueries = { ], }; +const queryReport = { + query_id: 31, + results: [ + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "Razer Viper", + vendor: "Razer", + model_id: "0078", + }, + }, + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "USB Keyboard", + vendor: "VIA Labs, Inc.", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Keyboard", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "YubiKey OTP+FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "PixArt", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo Traditional USB Keyboard", + vendor: "Lenovo", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Bose", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple, Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Logitech Webcam C925e", + model_id: "085b", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Ambient Light Sensor", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "DELL Laser Mouse", + model_id: "4d51", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "AppleUSBVHCIBCE Root Hub Simulation", + vendor: "Apple Inc.", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "QuickFire Rapid keyboard", + vendor: "CM Storm", + model_id: "0004", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "Lenovo", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "YubiKey FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 4, + host_name: "car", + last_fetched: "2023-01-14T12:40:30Z", + columns: { + model: "USB2.0 Hub", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "FaceTime HD Camera (Display)", + vendor: "Apple Inc.", + model_id: "1112", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Internal Keyboard / Trackpad", + model_id: "027e", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Thunderbolt Display", + vendor: "Apple Inc.", + model_id: "9227", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "AppleUSBXHCI Root Hub Simulation", + vendor: "Apple Inc.", + model_id: "8007", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple T2 Controller", + vendor: "Apple Inc.", + model_id: "8233", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "4-Port USB 2.0 Hub", + vendor: "Generic", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB 10_100_1000 LAN", + vendor: "Realtek", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Mouse", + vendor: "Razor", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Audio", + vendor: "Apple, Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + wOAnow: "that's a weird column!", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 99, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 999, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9990, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + ], +}; + const globalQuery1 = { query: globalQueries.queries[0] }; const globalQuery2 = { query: globalQueries.queries[1] }; const globalQuery3 = { query: globalQueries.queries[2] }; @@ -611,6 +10592,7 @@ export default { count, hosts, labels, + queryReport, globalQueries, globalQuery1, globalQuery2, diff --git a/frontend/styles/var/_global.scss b/frontend/styles/var/_global.scss index 0c772c21e80f..7876654c4cfb 100644 --- a/frontend/styles/var/_global.scss +++ b/frontend/styles/var/_global.scss @@ -1,2 +1,3 @@ $border-radius: 4px; $border-radius-large: 6px; +$border-radius-xlarge: 10px; diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index a7e8215dfa91..ab3e7406160c 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -9,11 +9,11 @@ $max-width: 2560px; @content; } } @else if ($size == ltdesktop) { - @media (max-width: $desktop-width - 1) { + @media (max-width: ($desktop-width - 1)) { @content; } } @else if ($size == desktop) { - @media (min-width: $medium-width + 1) { + @media (min-width: ($medium-width + 1)) { @content; } } @else if ($size == smalldesk) { @@ -108,3 +108,10 @@ $max-width: 2560px; list-style-position: inside; } } + +@mixin disabled { + opacity: 0.5; + filter: grayscale(0.5); + pointer-events: none; + cursor: default; +} diff --git a/frontend/utilities/constants.ts b/frontend/utilities/constants.ts index 14530e240bff..b911d225a63d 100644 --- a/frontend/utilities/constants.ts +++ b/frontend/utilities/constants.ts @@ -96,12 +96,17 @@ export const MIN_OSQUERY_VERSION_OPTIONS = [ { label: "1.8.1 +", value: "1.8.1" }, ]; -export const QUERIES_PAGE_STEPS = { +export const LIVE_POLICY_STEPS = { 1: "EDITOR", 2: "TARGETS", 3: "RUN", }; +export const LIVE_QUERY_STEPS = { + 1: "TARGETS", + 2: "RUN", +}; + export const DEFAULT_QUERY: ISchedulableQuery = { description: "", name: "", @@ -109,6 +114,7 @@ export const DEFAULT_QUERY: ISchedulableQuery = { id: 0, interval: 0, observer_can_run: false, + discard_data: false, platform: "", min_osquery_version: "", automations_enabled: false, diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index ce6d89b9528b..c78504a63424 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -51,7 +51,7 @@ export default { MDM_PROFILE: (id: number) => `/${API_VERSION}/fleet/mdm/apple/profiles/${id}`, MDM_UPDATE_APPLE_SETTINGS: `/${API_VERSION}/fleet/mdm/apple/settings`, MDM_PROFILES_AGGREGATE_STATUSES: `/${API_VERSION}/fleet/mdm/apple/profiles/summary`, - MDM_APPLE_DISK_ENCRYPTION_AGGREGATE: `/${API_VERSION}/fleet/mdm/apple/filevault/summary`, + MDM_DISK_ENCRYPTION_SUMMARY: `/${API_VERSION}/fleet/mdm/disk_encryption/summary`, MDM_APPLE_SSO: `/${API_VERSION}/fleet/mdm/sso`, MDM_APPLE_ENROLLMENT_PROFILE: (token: string, ref?: string) => { const query = new URLSearchParams({ token }); @@ -82,7 +82,7 @@ export default { PERFORM_REQUIRED_PASSWORD_RESET: `/${API_VERSION}/fleet/perform_required_password_reset`, QUERIES: `/${API_VERSION}/fleet/queries`, RESET_PASSWORD: `/${API_VERSION}/fleet/reset_password`, - RUN_QUERY: `/${API_VERSION}/fleet/queries/run`, + LIVE_QUERY: `/${API_VERSION}/fleet/queries/run`, SCHEDULE_QUERY: `/${API_VERSION}/fleet/packs/schedule`, SCHEDULED_QUERIES: (packId: number): string => { return `/${API_VERSION}/fleet/packs/${packId}/scheduled`; diff --git a/frontend/utilities/generate_csv/index.ts b/frontend/utilities/generate_csv/index.ts index 8ee514ef93ee..501441a8876e 100644 --- a/frontend/utilities/generate_csv/index.ts +++ b/frontend/utilities/generate_csv/index.ts @@ -14,7 +14,7 @@ export const generateCSVFilename = (descriptor: string) => { return `${descriptor} (${format(new Date(), "MM-dd-yy hh-mm-ss")}).csv`; }; -// Query results and query errors +// Live query results, live query errors, and query report export const generateCSVQueryResults = ( rows: Row[], filename: string, @@ -35,7 +35,7 @@ export const generateCSVQueryResults = ( ); }; -// Policy results only +// Live policy results only export const generateCSVPolicyResults = ( rows: { host: string; status: string }[], filename: string @@ -45,7 +45,7 @@ export const generateCSVPolicyResults = ( }); }; -// Policy errors only +// Live policy errors only export const generateCSVPolicyErrors = ( rows: ICampaignError[], filename: string diff --git a/frontend/utilities/helpers.ts b/frontend/utilities/helpers.ts index 641b5cf73451..89b291524b0d 100644 --- a/frontend/utilities/helpers.ts +++ b/frontend/utilities/helpers.ts @@ -31,7 +31,7 @@ import { } from "interfaces/scheduled_query"; import { ISelectTargetsEntity, - ISelectedTargets, + ISelectedTargetsForApi, IPackTargets, } from "interfaces/target"; import { ITeam, ITeamSummary } from "interfaces/team"; @@ -258,7 +258,7 @@ const formatLabelResponse = (response: any): ILabel[] => { export const formatSelectedTargetsForApi = ( selectedTargets: ISelectTargetsEntity[] -): ISelectedTargets => { +): ISelectedTargetsForApi => { const targets = selectedTargets || []; // TODO: can flatMap be removed? const hostIds = flatMap(targets, filterTarget("hosts")); @@ -910,6 +910,12 @@ export const getSoftwareBundleTooltipMarkup = (bundle: string) => { `; }; +export const TAGGED_TEMPLATES = { + queryByHostRoute: (hostId: number | undefined | null) => { + return `${hostId ? `?host_ids=${hostId}` : ""}`; + }, +}; + export default { addGravatarUrlToResource, formatConfigDataForServer, @@ -945,4 +951,5 @@ export default { syntaxHighlight, normalizeEmptyValues, wrapFleetHelper, + TAGGED_TEMPLATES, }; diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index c535c8013151..d6eb7b6c89bd 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -1,6 +1,10 @@ -import { FileVaultProfileStatus, BootstrapPackageStatus } from "interfaces/mdm"; import { isEmpty, reduce, omitBy, Dictionary } from "lodash"; -import { MacSettingsStatusQueryParam } from "services/entities/hosts"; + +import { DiskEncryptionStatus, BootstrapPackageStatus } from "interfaces/mdm"; +import { + DISK_ENCRYPTION_QUERY_PARAM_NAME, + MacSettingsStatusQueryParam, +} from "services/entities/hosts"; type QueryValues = string | number | boolean | undefined | null; export type QueryParams = Record; @@ -24,7 +28,7 @@ interface IMutuallyExclusiveHostParams { osId?: number; osName?: string; osVersion?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; } @@ -123,7 +127,7 @@ export const reconcileMutuallyExclusiveHostParams = ({ case !!lowDiskSpaceHosts: return { low_disk_space: lowDiskSpaceHosts }; case !!diskEncryptionStatus: - return { macos_settings_disk_encryption: diskEncryptionStatus }; + return { [DISK_ENCRYPTION_QUERY_PARAM_NAME]: diskEncryptionStatus }; case !!bootstrapPackageStatus: return { bootstrap_package: bootstrapPackageStatus }; default: diff --git a/go.mod b/go.mod index 7913243bd99b..48a36f955912 100644 --- a/go.mod +++ b/go.mod @@ -272,6 +272,7 @@ require ( github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 // indirect github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/slack-go/slack v0.9.4 // indirect diff --git a/go.sum b/go.sum index ca02b6bc31b3..9c99a41dc39c 100644 --- a/go.sum +++ b/go.sum @@ -1080,6 +1080,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA= +github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 h1:rc/CcqLH3lh8n+csdOuDfP+NuykE0U6AeYSJJHKDgSg= +github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9/go.mod h1:a/83NAfUXvEuLpmxDssAXxgUgrEy12MId3Wd7OTs76s= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie v1.0.0 h1:9GNhIat69MSlz/ndaBg48vl9dF5fI+NBB6kfOxgfkMc= github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md index 5c6f8c710e3b..17a9a3569032 100644 --- a/handbook/business-operations/README.md +++ b/handbook/business-operations/README.md @@ -35,6 +35,7 @@ Certain new team members, especially in go-to-market (GTM) roles, will need paid | 🐋 SC | ✅ | ✅ | ❌ | ❌ | ✅ | 🫧 SDR | ✅ | ✅ | ✅ | ❌ | ❌ | ⚗️ PM | ❌ | ❌ | ❌ | ✅ | ✅ +| ⚗️ PD | ❌ | ❌ | ❌ | ✅ | ✅ | 🔦 CEO | ✅ | ✅ | ✅ | ✅ | ✅ | Other roles | ❌ | ❌ | ❌ | ❌ | ❌ diff --git a/handbook/ceo.md b/handbook/ceo.md index 864baefee657..e74b1e3aa95b 100644 --- a/handbook/ceo.md +++ b/handbook/ceo.md @@ -17,7 +17,7 @@ The CEO is the [directly responsible individual](https://fleetdm.com/handbook/co - Please use issue comments and GitHub mentions to communicate follow-ups or answer questions related to your request. - Any Fleet team member can view the [🐈‍⬛#g-ceo kanban board](https://app.zenhub.com/workspaces/-g-ceo-645b0eab68a4d40c0795ff61/board?sprints=none) (confidential) for this team, including pending tasks and requests. - **Do not add events to the CEO's calendar.** events added directly to the CEO's calendar will be declined and removed. Even if the CEO asks you to set up a meeting or add him to a call, please [get scheduling help from the Apprentice](#schedule-time-with-the-ceo). -- **For personal or extremely urgent requests** that cannot wait one business day, send a Slack direct message (DM) to `@mikermcneil` right away. +- **For personal or extremely urgent requests** that cannot wait one business day, send a Slack direct message (DM) to `@mikermcneil` right away 🎵 - If you mention the CEO or reply from within a Slack thread, he [will not read your message](#why-not-mention-the-ceo-in-slack-threads). - **If you're a hiring manager**, you can [schedule a CEO interview](https://github.com/fleetdm/confidential/issues/new?assignees=sampfluger88&labels=%23g-ceo&projects=&template=&title=CEO%20interview%3a%20%7BCANDIDATE_NAME%7D&body=-%20[%20]%20I%20followed%20all%20the%20steps%20in%20https%3A%2F%2Ffleetdm.com%2Fhandbook%2Fcompany%2Fleadership%23hiring-a-new-team-member%20before%20submitting%20this%20issue.) - **If you're in Business Operations**, you can [request warehoused equipment be shipped from Fleet IT](#request-equipment-from-fleet-it). diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index 233d074e7ddf..5aaa55deae47 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -220,6 +220,25 @@ - ➕ Bonus: Experienced with Go. - 💭 3-5 years' of experience in cloud infrastructure (AWS/GCP/Azure). - 🦉 Proficient in infrastructure as code and container deployments. +- jobTitle: ⚗️ Product Manager, Security + department: Product + hiringManagerName: Mo Zhu + hiringManagerLinkedInUrl: linkedin.com/in/mo-zhu + hiringManagerGithubUsername: zhumo + responsibilities: | + - 🤝 Deeply understand customer needs and workflows through direct conversations + - 🛣️ Use gained customer understanding to inform product direction and strategy + - 💬 Translate between technical and business needs, orally and in writing + - 📝 Write user stories and requirements to guide design and engineering + - 🛫 Oversee product development lifecycle from concept to release to documentation and support + - 🎁 Develop go-to-market strategies and launch plans for new cybersecurity products, including pricing, positioning, and messaging. + - 🔎 Conduct competitive product analysis + - 👯 Communicate and build strong relationships with cross-functional teams and stakeholders + experience: | + - ⚗️ 2+ years experience as a product manager at a software technology firm + - ⚙️ 2+ years experience as a technical contributor at a software technology firm + - 🏃 Ability to work in a fast-paced startup environment + - 🔒 Bonus: experience in cybersecurity # Note: commenting out this open position because the page link did not exist in the current version of the company handbook page. (2023-08-31) # - jobTitle: 🐋 Solutions Consultant diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index 775b50b171f7..ad63dd38a30c 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -1,193 +1,268 @@ +- categoryName: Other + features: + - industryName: File integrity monitoring (FIM) # Short industry phrase + friendlyName: Detect changes to critical files # Short, Fleet one-liner for the feature, written in the imperative mood. (If easy to do, base this off of the words that an actual customer is saying.) + description: Specify files to monitor for changes or deletions, then log those events to your SIEM or data lake, including key information such as filepath and checksum. # Clear Mr. Rogers description + documentationUrl: https://fleetdm.com/guides/osquery-evented-tables-overview#file-integrity-monitoring-fim # URL of the single-best page within the docs which serves as a "jumping-off point" for this feature. + screenshotSrc: "" # A screenshot of the single, best, simplifying, obvious example + tier: Free # Either "Free" or "Premium" + usualDepartment: Security # or omit if there isn't a particular departmental leaning we've noticed + productCategories: [Endpoint operations] # or omit if this isn't associated with a single product category + dri: mikermcneil #GitHub user name + demos: + - description: A top gaming company needed a way to monitor critical files on production Debian servers. + quote: The FIM features are kind of a top priority. + moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit + cues: + - description: Monitor critical files on production Debian servers + - description: Detect illicit activity + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Pinpoint unintended changes + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Verify update status and monitoring system health + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Meet compliance mandates + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - industryName: Human-endpoint mapping + friendlyName: See who logs in on every computer + description: Identify who logs in to any system, including login history and current sessions. Look up any host by the email address of the person using it. + documentationUrl: "" # todo + screenshotSrc: "" + tier: Free + productCategories: [Endpoint operations] + dri: mikermcneil + demos: + - description: Security engineers at a top gaming company wanted to get demographics off their macOS, Windows, and Linux machines about who the user is and who's logged in. + moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit + cues: + - description: Human-to-device mapping + - description: Look up computer by ActiveDirectory account + - description: Find device by Google Chrome user + - description: Check user login history + moreInfoUrl: https://www.lepide.com/how-to/audit-who-logged-into-a-computer-and-when.html#:~:text=To%20find%20out%20the%20details,logs%20in%20%E2%80%9CWindows%20Logs%E2%80%9D. + - description: See currently logged in users + moreInfoUrl: https://www.top-password.com/blog/see-currently-logged-in-users-in-windows/ + - description: Get demographics off of our machines about who the user is and who's logged in + moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit + - description: See what servers someone is logged-in on + moreInfoUrl: https://community.spiceworks.com/topic/138171-is-there-a-way-to-see-what-servers-someone-is-logged-in-on + - industryName: REST API + friendlyName: Automate any feature + description: "" + documentationUrl: https://fleetdm.com/docs/rest-api/rest-api + screenshotSrc: "" + tier: Free + dri: rachaelshaw + - industryName: Command line tool (CLI) + tier: Free - categoryName: Device management features: - - name: User-initiated enrollment of macOS computers + - industryName: User-initiated enrollment of macOS computers tier: Free - comingSoon: false - - name: Remotely enforce macOS settings + usualDepartment: IT + productCategories: [Device management] + - industryName: Remotely enforce macOS settings tier: Free - comingSoon: false - - name: Low-level macOS MDM commands (e.g. remote restart) + usualDepartment: IT + productCategories: [Device management] + - industryName: Low-level macOS MDM commands (e.g. remote restart) tier: Free - comingSoon: false - - name: Native macOS update reminders + usualDepartment: IT + productCategories: [Device management] + - industryName: Native macOS update reminders tier: Free - comingSoon: false - - name: Zero-touch setup for macOS computers + usualDepartment: IT + productCategories: [Device management] + - industryName: Zero-touch setup for macOS computers tier: Premium - comingSoon: false - - name: Safely execute custom scripts (macOS, Windows, and Linux) + usualDepartment: IT + productCategories: [Device management] + - industryName: Safely execute custom scripts (macOS, Windows, and Linux) tier: Premium - comingSoon: false - - name: End-user macOS update reminders (via Nudge) + productCategories: [Device management, Endpoint operations] + - industryName: End-user macOS update reminders (via Nudge) tier: Premium - comingSoon: false - - name: Encrypt macOS hard disks with FileVault + usualDepartment: IT + productCategories: [Device management] + - industryName: Encrypt macOS hard disks with FileVault tier: Premium - comingSoon: false - - name: Manage queued MDM commands on macOS + usualDepartment: IT + productCategories: [Device management] + - industryName: Manage queued MDM commands on macOS tier: Premium - comingSoon: true - - name: Remotely lock and wipe macOS computers + comingSoonOn: 2023-12-31 + usualDepartment: IT + productCategories: [Device management] + - industryName: Remotely lock and wipe macOS computers tier: Premium - comingSoon: false - - name: Update apps on macOS computers + usualDepartment: IT + productCategories: [Device management] + - industryName: Update apps on macOS computers tier: Premium - comingSoon: true - - name: Puppet integration # « Map macOS settings to computers with Puppet module + comingSoonOn: 2024-03-31 + usualDepartment: IT + productCategories: [Device management] + - industryName: Puppet integration + friendlyName: Map macOS settings to computers with Puppet module tier: Premium - comingSoon: false - - name: Interactive MDM migration # « end-user initiated MDM migration, with interactive UI + usualDepartment: IT + productCategories: [Device management] + - industryName: Interactive MDM migration # « end-user initiated MDM migration, with interactive UI tier: Premium - comingSoon: false + usualDepartment: IT + productCategories: [Device management] - categoryName: Support features: - - name: Public issue tracker (GitHub) + - industryName: Public issue tracker (GitHub) tier: Free - comingSoon: false - - name: Community Slack channel + - industryName: Community Slack channel tier: Free - comingSoon: false - - name: Unlimited email support (confidential) + - industryName: Unlimited email support (confidential) tier: Premium - comingSoon: false - - name: Phone and video call support + - industryName: Phone and video call support tier: Premium - comingSoon: false - categoryName: Inventory management features: - - name: Secure REST API - tier: Free - comingSoon: false - - name: Command line tool (CLI) - tier: Free - comingSoon: false - - name: Realtime device inventory dashboard + - industryName: Device inventory dashboard tier: Free - comingSoon: false - - name: Browse installed software packages + - industryName: Browse installed software packages tier: Free - comingSoon: false - - name: Search devices by IP, serial, hostname, UUID + - industryName: Search devices by IP, serial, hostname, UUID tier: Free - comingSoon: false - - name: Target and configure specific groups of devices + - industryName: Target and configure specific groups of devices tier: Premium - comingSoon: false - - name: Aggregate insights for groups of devices + - industryName: Generate reports for groups of devices tier: Premium - comingSoon: false - categoryName: Collaboration features: - - name: Shareable device health reports + - industryName: Shareable device health reports tier: Free - comingSoon: false - - name: Versionable queries and config (GitOps) + - industryName: Versionable queries and config (GitOps) tier: Free - comingSoon: false - - name: Human-to-device mapping + demos: + - description: A top financial services company needed to set up rolling deployments for changes to osquery agents running on their production servers. + moreInfoUrl: https://docs.google.com/document/d/1UdzZMyBLbs9SUXfSXN2x2wZQCbjZZUetYlNWH6-ryqQ/edit#heading=h.2lh6ehprpvl6 + - industryName: Scope transparency tier: Free - comingSoon: false - - name: Scope transparency - tier: Free - comingSoon: false + moreInfoUrl: https://fleetdm.com/transparency - categoryName: Security and compliance features: - - name: Single sign on (SSO, SAML) - tier: Free - comingSoon: false - - name: Report on disk encryption status (FileVault) - tier: Free - comingSoon: false - - name: Audit queries and user activities + - industryName: Single sign on (SSO, SAML) tier: Free - comingSoon: false - - name: Grant API-only access + - industryName: Disk encryption + friendlyName: Ensure hard disks are encrypted + description: Encrypt hard disks of macOS and Windows computers, manage escrowed encryption keys, and report on disk encryption status (FileVault, BitLocker). tier: Free - comingSoon: false - - name: Role-based access control + cues: + - description: Report on disk encryption status + - description: Encrypt hard disks on macOS with FileVault + - description: Escrow FileVault keys on macOS + - description: Encrypt hard disks on Windows with BitLocker + - industryName: Audit queries and user activities tier: Free - comingSoon: false - - name: Ship logs to Splunk, Snowflake, and more + usualDepartment: Security + - industryName: Grant API-only access tier: Free - comingSoon: false - - name: Programmable audit log + - industryName: Programmable audit log tier: Premium - comingSoon: false - - name: Just-in-time (JIT) provisioning + usualDepartment: Security + cues: + - description: Export activity of Fleet admins to your SIEM or data lake + - industryName: Just-in-time (JIT) provisioning tier: Premium - comingSoon: false - - name: Automated user role sync via Okta, AD, or any IDP + - industryName: Automated user role sync via Okta, AD, or any IDP tier: Premium - comingSoon: false - - name: Vanta integration + cue: + - description: Automatically set admin access to Fleet based on your IDP + - industryName: Vanta integration tier: Premium - comingSoon: false - - name: Trigger a workflow based on a failing policy + - industryName: Trigger a workflow based on a failing policy tier: Premium - comingSoon: true - - name: Granular role-based access control + - industryName: Role-based access control tier: Premium - comingSoon: false - categoryName: Monitoring features: - - name: Schedule and automate custom queries - tier: Free - comingSoon: false - - name: Detect vulnerable software - tier: Free - comingSoon: false - - name: Query performance monitoring - tier: Free - comingSoon: false - - name: Standard query and policy library - tier: Free - comingSoon: false - - name: Policy and vulnerability automations (webhook, Zendesk, JIRA, ServiceNow*) - tier: Free - comingSoon: false - - name: Detect and surface issues with devices (policies) - tier: Free - comingSoon: false - - name: Mark policies as critical + - industryName: Schedule and automate custom queries + tier: Free + usualDepartment: Security + cues: + - description: Ship logs to Splunk, Snowflake, and more + - description: Export the data to other systems + moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit + - description: Export data to a third-party SIEM tool + moreInfoUrl: https://www.websense.com/content/support/library/web/hosted/admin_guide/siem_integration_explain.aspx + - industryName: Detect vulnerable software + tier: Free + usualDepartment: Security + productCategories: [Vulnerability management] + demos: + - description: A top gaming company wanted to replace Qualys for infrastructure vulnerability detection. + quote: So we have some stuff today through Qualys, but it's just not very good. A lot of it is...it's just really noisy. I'm trying to find out specifically, actually what packages are installed where, and then the ability to live query them. + moreInfoUrl: https://docs.google.com/document/d/1JWtRsW1FUTCkZEESJj9-CvXjLXK4219by-C6vvVVyBY/edit + - industryName: Query performance monitoring + tier: Free + demos: + - description: A top software company needed to understand the performance impact of osquery queries before running them on all of their production Linux servers. + moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg + - description: A top software company wanted to detect regressions when adding/changing queries and fail builds if queries were too expensive. + moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg + - industryName: Device trust + tier: Free + cue: + - description: Standard query and policy library + - description: Beyondcorp + - description: Zero trust + - description: Conditional access + - industryName: Policy and vulnerability automations (webhook, Zendesk, JIRA, ServiceNow*) + tier: Free + - industryName: Detect and surface issues with devices (policies) + tier: Free + - industryName: Mark policies as critical tier: Premium - comingSoon: false - - name: Vulnerability scores (EPSS and CVSS) + - industryName: Vulnerability scores (EPSS and CVSS) tier: Premium - comingSoon: false - - name: CISA known exploited vulnerabilities + usualDepartment: Security + productCategories: [Vulnerability management] + - industryName: CISA known exploited vulnerabilities tier: Premium - comingSoon: false - - name: End-user self-service + usualDepartment: Security + productCategories: [Vulnerability management] + - industryName: End-user self-service tier: Premium - comingSoon: false + usualDepartment: IT + productCategories: [Device management, Endpoint operations] - categoryName: Data outputs features: - - name: Flexible log destinations (AWS Kinesis, Lambda, GCP, Kafka) + - industryName: Flexible log destinations (AWS Kinesis, Lambda, GCP, Kafka) tier: Free - comingSoon: false - - name: File carving (AWS S3) + usualDepartment: Security + productCategories: [Endpoint operations] + - industryName: File carving (AWS S3) tier: Free - comingSoon: false + usualDepartment: Security + productCategories: [Endpoint operations] - categoryName: Deployment features: - - name: Self-hosted + - industryName: Self-hosted tier: Free - comingSoon: false - - name: Deployment tools (Helm, Terraform) + cues: + - description: Self-managed + - description: Host it yourself + - industryName: Deployment tools (Terraform, Helm) tier: Free - comingSoon: false - - name: Configure osquery startup flags remotely + - industryName: Configure osquery startup flags remotely tier: Free - comingSoon: false - - name: Auto-update osquery agents + usualDepartment: Security + productCategories: [Endpoint operations] + - industryName: Auto-update osquery agents tier: Free - comingSoon: false - - name: Self-managed auto-update registry + productCategories: [Endpoint operations] + - industryName: Self-managed auto-update registry tier: Premium - comingSoon: false - - name: Manage osquery extensions remotely + usualDepartment: Security + productCategories: [Endpoint operations] + - industryName: Manage osquery extensions remotely tier: Premium - comingSoon: false - - name: Managed Cloud + productCategories: [Endpoint operations] + - industryName: Managed Cloud tier: Premium - comingSoon: false diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index c0245dee189d..a736f8c35c3d 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -51,7 +51,7 @@ The goal of the customer experience (CX) group is to make users and customers ha | Engineering manager | Sharon Katz | Quality assurance | Reed Haynes | Product manager | Mo Zhu -| Software engineers (developers) | Jacob Shandling, Lucas Rodriguez, Rachel Perkins, Eric Shaw, Tim Lee +| Software engineers (developers) | Jacob Shandling, Lucas Rodriguez, Rachel Perkins, Eric Shaw, Tim Lee, Victor Lyuboslavsky > The Slack channel, kanban release board, and label for this product group is `#g-cx`. diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index 962c707227b1..071ea4a7c422 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -13,7 +13,7 @@ Fleet's source code, website, documentation, company handbook, and internal tool Meanwhile, the [company behind Fleet](https://twitter.com/fleetctl) is built on the [open-core](https://www.heavybit.com/library/video/commercial-open-source-business-strategies) business model. Openness is one of our core [values](https://fleetdm.com/handbook/company#values), and everything we do is [public by default](https://handbook.gitlab.com/handbook/values/#public-by-default). Even the [company handbook](https://fleetdm.com/handbook) is open to the world. -Is open-source collaboration _really_ worth all that? Is it any good? +Is open-source collaboration _all that_? Is it any good? Here are some of the reasons we build in the open: diff --git a/handbook/engineering/Load-testing.md b/handbook/engineering/Load-testing.md index bcdab25be482..ac93f165656e 100644 --- a/handbook/engineering/Load-testing.md +++ b/handbook/engineering/Load-testing.md @@ -58,7 +58,7 @@ After the hosts have been enrolled, you can add `-only_already_enrolled` to make ## Infrastructure setup -The deployment of Fleet was done through the loadtesting [terraform maintained in the repo](https://github.com/fleetdm/fleet/tree/main/tools/loadtesting/terraform) with the following command: +The deployment of Fleet was done through the loadtesting [terraform maintained in the repo](https://github.com/fleetdm/fleet/tree/main/infrastructure/loadtesting/terraform) with the following command: ```bash terraform apply -var tag= diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index df351374302d..a62495ae375e 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -1,36 +1,57 @@ # Engineering +This handbook page details processes specific to working [with](#team) and [within](#responsibilities) this department -## Scrum at Fleet +## What we do +The 🚀 Engineering department at Fleet is directly responsible for writing and maintaining the [code](https://github.com/fleetdm/fleet) for Fleet's core product and infrastructure. -- [Sprint ceremonies](#sprint-ceremonies) -- [Scrum boards](#scrum-boards) -- [Scrum items](#scrum-items) -Fleet [product groups](https://fleetdm.com/handbook/company/development-groups#what-are-product-groups) employ scrum, an agile methodology, as a core practice in software development. This process is designed around sprints, which last three weeks to align with our release cadence. +## Team +| Role | Contributor(s) | +|:--------------------------------|:-----------------------------------------------------------------------------------------------------------| +| Director of Product Devleopment | [Luke Heath](https://www.linkedin.com/in/lukeheath/) _([@lukeheath](https://github.com/lukeheath))_ +| Engineering Managers | [George Karr](https://www.linkedin.com/in/george-karr-4977b441/) _([@georgekarrv](https://github.com/georgekarrv))_, [Sharon Katz](https://www.linkedin.com/in/sharon-katz-45b1b3a/) _([@sharon-fdm](https://github.com/sharon-fdm))_ +| Product Quality Specialists | [Reed Haynes](https://www.linkedin.com/in/george-karr-4977b441/) _([@xpkoala](https://github.com/xpkoala))_, [Sabrina Coy](https://www.linkedin.com/in/bricoy/) _([@sabrinabuckets](https://github.com/sabrinabuckets))_ +| Infrastructure Engineers | [Robert Fairburn](https://www.linkedin.com/in/robert-fairburn/) _([@rfairburn](https://github.com/rfairburn))_ +| Developers | _See ["Current product groups"](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ -### Sprint ceremonies + -Each sprint is marked by five essential ceremonies: +## Contact us +- Any community memeber can file a 🦟 ["Bug report"](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=) + - Any Fleet team member can view the 🦟 ["Bugs" kanban board](https://app.zenhub.com/workspaces/-bugs-647f6d382e171b003416f51a/board) including the status on all reported bugs. +- If urgent, or if you need help submiting an issue, mention a [team member](#team) in the [#help-engineering](https://fleetdm.slack.com/archives/C019WG4GH0A) Slack channel. +- Any Fleet team member can view the dedicated sprint boards managed by this department: + - 💻 [MDM (#g-mdm)](https://app.zenhub.com/workspaces/-g-mdm-current-sprint-63bc507f6558550011840298/board) + - 🌟 [Customer experience (#g-cx)](https://app.zenhub.com/workspaces/-g-cx-current-sprint-63bd7e0bf75dba002a2343ac/board) + - ⚙️ [Infra (#g-infra)](https://app.zenhub.com/workspaces/-g-infra-642c83a53e96760014c978bd/board) -1. **Sprint kickoff**: On the first day of the sprint, the team, along with stakeholders, select items from the backlog to work on. The team then commits to completing these items within the sprint. -2. **Daily standup**: Every day, the team convenes for updates. During this session, each team member shares what they accomplished since the last standup, their plans until the next meeting, and any blockers they are experiencing. Standups should last no longer than fifteen minutes. If additional discussion is necessary, it takes place after the standup with only the required partipants. -3. **Weekly estimation sessions**: The team estimates backlog items once a week (three times per sprint). These sessions help to schedule work completion and align the roadmap with business needs. They also provide estimated work units for upcoming sprints. The EM is responsible for the point values assigned to each item and ensures they are as realistic as possible. -4. **Sprint demo**: On the last day of each sprint, all engineering teams and stakeholders come together to review completed work. Engineers are allotted 3-10 minutes to present their accomplishments, as well as any pending tasks. (These meetings are recorded and posted publicly to YouTube or other platforms, so participants should avoid mentioning customer names. For example, instead of "Fastly", you can say "a publicly-traded hosting company", or use the [customer's codename](https://fleetdm.com/handbook/customers#customer-codenames).) -5. **Sprint retrospective**: Also held on the last day of the sprint, this meeting encourages discussions among the team and stakeholders around three key areas: what went well, what could have been better, and what the team learned during the sprint. + -### Scrum boards -Each product group has a dedicated sprint board: -- [MDM](https://app.zenhub.com/workspaces/-g-mdm-current-sprint-63bc507f6558550011840298/board) -- [CX](https://app.zenhub.com/workspaces/-g-cx-current-sprint-63bd7e0bf75dba002a2343ac/board) -- [Website](https://app.zenhub.com/workspaces/-g-website-6451748b4eb15200131d4bab/board) -- [Infra](https://app.zenhub.com/workspaces/-g-infra-642c83a53e96760014c978bd/board) +## Scrum at Fleet +- [Sprint ceremonies](#sprint-ceremonies) +- [Scrum boards](#scrum-boards) +- [Scrum items](#scrum-items) + +Fleet [product groups](https://fleetdm.com/handbook/company/development-groups#what-are-product-groups) employ scrum, an agile methodology, as a core practice in software development. This process is designed around sprints, which last three weeks to align with our release cadence. New tickets are estimated, specified, and prioritized on the roadmap: - [Roadmap](https://app.zenhub.com/workspaces/-roadmap-ships-in-6-weeks-6192dd66ea2562000faea25c/board) ### Scrum items - Our scrum boards are exclusively composed of four types of scrum items: 1. **User stories**: These are simple and concise descriptions of features or requirements from the user's perspective, marked with the `story` label. They keep our focus on delivering value to our customers. Occasionally, due to ZenHub's ticket sub-task structure, the term 'epic' may be seen. However, we treat these as regular user stories. @@ -43,8 +64,19 @@ Our scrum boards are exclusively composed of four types of scrum items: > Our sprint boards do not accommodate any other type of ticket. By strictly adhering to these four types of scrum items, we maintain an organized and focused workflow that consistently adds value for our users. -## Meetings +### Sprint ceremonies +Each sprint is marked by five essential ceremonies: + +1. **Sprint kickoff**: On the first day of the sprint, the team, along with stakeholders, select items from the backlog to work on. The team then commits to completing these items within the sprint. +2. **Daily standup**: Every day, the team convenes for updates. During this session, each team member shares what they accomplished since the last standup, their plans until the next meeting, and any blockers they are experiencing. Standups should last no longer than fifteen minutes. If additional discussion is necessary, it takes place after the standup with only the required partipants. +3. **Weekly estimation sessions**: The team estimates backlog items once a week (three times per sprint). These sessions help to schedule work completion and align the roadmap with business needs. They also provide estimated work units for upcoming sprints. The EM is responsible for the point values assigned to each item and ensures they are as realistic as possible. +4. **Sprint demo**: On the last day of each sprint, all engineering teams and stakeholders come together to review completed work. Engineers are allotted 3-10 minutes to present their accomplishments, as well as any pending tasks. (These meetings are recorded and posted publicly to YouTube or other platforms, so participants should avoid mentioning customer names. For example, instead of "Fastly", you can say "a publicly-traded hosting company", or use the [customer's codename](https://fleetdm.com/handbook/customers#customer-codenames).) +5. **Sprint retrospective**: Also held on the last day of the sprint, this meeting encourages discussions among the team and stakeholders around three key areas: what went well, what could have been better, and what the team learned during the sprint. + + + +## Meetings - [Goals](#goals) - [Principles](#principles) - [Sprint ceremonies](#sprint-ceremonies) @@ -55,29 +87,19 @@ Our scrum boards are exclusively composed of four types of scrum items: - [Eng product bi-weekly](#eng-product-bi-weekly) - [Product development process review](#product-development-process-review) -### Goals - -- Stay in alignment across the whole organization. -- Build teams, not groups of people. -- Provide substantial time for engineers to work on "focused work." - ### Principles - - Support the [Maker Schedule](http://www.paulgraham.com/makersschedule.html) by keeping meetings to a minimum. - Each individual must have a weekly or biweekly sync 1:1 meeting with their manager. This is key to making sure each individual has a voice within the organization. - Favor async communication when possible. This is very important to make sure every stakeholder on a project can have a clear understanding of what’s happening or what was decided, without needing to attend every meeting (i.e., if a person is sick or on vacation or just life happened.) - If an async conversation is not proving to be effective, never hesitate to hop on or schedule a call. Always document the decisions made in a ticket, document, or whatever makes sense for the conversation. ### Eng Together - This meeting is to disseminate engineering-wide announcements, promote cohesion across groups within the engineering team, and connect with engineers (and the "engineering-curious") in other departments. Held monthly for one hour. #### Participants - Everyone at the company is welcome to attend. All engineers are asked to attend. The subject matter is focused on engineering. #### Agenda - - Announcements - Engineering KPIs review - “Tech talks” @@ -87,13 +109,11 @@ Everyone at the company is welcome to attend. All engineers are asked to attend. - Structured and/or unstructured social activities ### User story discovery - User story discovery meetings are scheduled as needed to align on large or complicated user stories. Before a discovery meeting is scheduled, the user story must be prioritized for product drafting and go through the design and specification process. When the user story is ready to be estimated, a user story discovery meeting may be scheduled to provide more dedicated, synchronous time for the team to discuss the user story than is available during weekly estimation sessions. All participants are expected to review the user story and associated designs and specifications before the discovery meeting. #### Participants - - Product Manager - Product Designer - Engineering Manager @@ -102,7 +122,6 @@ All participants are expected to review the user story and associated designs an - Product Quality Specialist #### Agenda - - Product Manager: Why this story has been prioritized - Product Designer: Walk through user journey wireframes - Engineering Manager: Review specifications and any defined sub-tasks @@ -110,47 +129,38 @@ All participants are expected to review the user story and associated designs an - Product Quality Specialist: Testing plan ### Group weeklies - A chance for deeper, synchronous discussion on topics relevant across product groups like “Frontend weekly”, “Backend weekly”, etc. #### Participants - Anyone who wishes to participate. #### Sample agenda (Frontend weekly) - - Discuss common patterns and conventions in the codebase - Review difficult frontend bugs - Write engineering-initiated stories ### Eng leadership weekly - Engineering leaders discuss topics of importance that week. Prepare agenda, announcements, and tech talks before the monthly [Eng Together](#eng-together) meeting. #### Participants - - Engineering Managers - Director of Product Development - CTO #### Rituals - 1. Review Engineering KPIs. 2. Review each product group's ZenHub board. 3. Proceed to agenda. #### Sample agenda - - Engineer hiring - Process discussion - New documentation needs ### Eng product bi-weekly - Engineering and product bi-weekly sync to discuss process, roadmap, and scheduling. #### Participants - - Head of Product - Product Managers (optional) - CTO @@ -158,31 +168,26 @@ Engineering and product bi-weekly sync to discuss process, roadmap, and scheduli - Engineering Managers (optional) #### Sample agenda - - Product to engineering handoff process - Q4 product roadmap - Optimizing development processes ### Product development process review - A once-per-sprint review of the bugs, drafting, and sprint boards to make sure that the current state of the boards reflects the process as defined in the handbook, or if any changes are needed to the documented process. #### Participants - - CEO - Head of Product - Product Operations - Director of Product Development #### Sample agenda - - Review bugs board - Review drafting board - Review sprint boards - How is the process working? Are any changes needed? ## Engineering-initiated stories - - [Creating an engineering-initiated story](#creating-an-engineering-initiated-story) Engineering-initiated stories are types of user stories created by engineers to make technical changes to Fleet. Technical changes should improve the user experience or contributor experience. For example, optimizing SQL that improves the response time of an API endpoint improves user experience by reducing latency. A script that generates common boilerplate, or automated tests to cover important business logic, improves the quality of life for contributors, making them happier and more productive, resulting in faster delivery of features to our customers. @@ -194,7 +199,6 @@ Engineering-initiated stories follow the [user story drafting process](https://f > We prefer the term engineering-initiated stories over technical debt because the user story format helps keep us focused on our users. ### Creating an engineering-initiated story - 1. Create a [new feature request issue](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=~engineering-initiated&projects=&template=feature-request.md&title=) in GitHub. 2. Ensure it is labeled with `~engineering-initiated` and the relevant product group. Remove any `~customer-request` label. 3. Assign it to yourself. You will own this user story until it is either prioritized or closed. @@ -204,29 +208,24 @@ Engineering-initiated stories follow the [user story drafting process](https://f > We aspire to dedicate 20% of each sprint to technical changes, but may allocate less based on customer needs and business priorities. ## Documentation for contributors - Fleet's documentation for contributors can be found in the [Fleet GitHub repo](https://github.com/fleetdm/fleet/tree/main/docs/Contributing). ## Release process - This section outlines the release process at Fleet. The current release cadence is once every three weeks and is concentrated around Wednesdays. ### Release freeze period - To ensure release quality, Fleet has a freeze period for testing beginning the Tuesday before the release at 9:00 AM Pacific. Effective at the start of the freeze period, new feature work will not be merged into `main`. Bugs are exempt from the release freeze period. ### Freeze day - To begin the freeze, [open the repo on Merge Freeze](https://www.mergefreeze.com/installations/3704/branches/6847) and click the "Freeze now" button. This will freeze the `main` branch and require any PRs to be manually unfrozen before merging. PRs can be manually unfrozen in Merge Freeze using the PR number. > Any Fleetie can [unfreeze PRs on Merge Freeze](https://www.mergefreeze.com/installations/3704/branches) if the PR contains documentation changes or bug fixes only. If the PR contains other changes, please confirm with your manager before unfreezing. #### Check dependencies - Before kicking off release QA, confirm that we are using the latest versions of dependencies we want to keep up-to-date with each release. Currently, those dependencies are: 1. **Go**: Latest minor release @@ -250,11 +249,9 @@ Before kicking off release QA, confirm that we are using the latest versions of Our goal is to keep these dependencies up-to-date with each release of Fleet. If a release is going out with an old dependency version, it should be treated as a [critical bug](https://fleetdm.com/handbook/engineering#critical-bugs) to make sure it is updated before the release is published. #### Create release QA issue - Next, create a new GitHub issue using the [Release QA template](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=&projects=&template=smoke-tests.md&title=). Add the release version to the title, and assign the quality assurance members of the [MDM](https://fleetdm.com/handbook/company/development-groups#mdm-group) and [CX](https://fleetdm.com/handbook/company/development-groups#customer-experience-group) product groups. ### Merging during the freeze period - We merge bug fixes and documentation changes during the freeze period, but we do not merge other code changes. This minimizes code churn and helps ensure a stable release. To merge a bug fix, you must first unfreeze the PR in [Merge Freeze](https://app.mergefreeze.com/installations/3704/branches), and click the "Unfreeze 1 pull request" text link. @@ -265,15 +262,12 @@ It is sometimes necessary to delay the release to allow time to complete partial 3. The Engineering Manager, QA lead, and [release ritual DRI](#rituals) must all approve the feature work PR before it is unfrozen and merged. ### Release readiness - After each product group finishes their QA process during the freeze period, the EM @ mentions the release ritual DRI in the #help-qa Slack channel. When all EMs have certified that they are ready for release, the release ritual DRI begins the [release process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md). ### Release day - Documentation on completing the release process can be found [here](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md). ## Deploying to dogfood - After each Fleet release, the new release is deployed to Fleet's dogfood (internal) instance. How to deploy a new release to dogfood: @@ -289,7 +283,6 @@ How to deploy a new release to dogfood: > Note that "fleetdm/fleet:main" is not a image name, instead use the commit hash in place of "main". ## Milestone release ritual - Immediately after publishing a new release, we close out the associated GitHub issues and milestones. ### Update milestone in GitHub @@ -297,7 +290,6 @@ Immediately after publishing a new release, we close out the associated GitHub i 1. **Rename current milestone**: In GitHub, [change the current milestone name](https://github.com/fleetdm/fleet/milestones) from `4.x.x-tentative` to `4.x.x`. `4.37.0-tentative` becomes `4.37.0`. ### ZenHub housekeeping - 2. **Update product group boards**: In ZenHub, go to each product group board tracking the current release. Usually, these are [#g-cx](https://app.zenhub.com/workspaces/-g-cx-current-sprint-63bd7e0bf75dba002a2343ac/board) and [#g-mdm](https://app.zenhub.com/workspaces/-g-mdm-current-sprint-63bc507f6558550011840298/board). 3. **Remove milestone from unfinished items**: If you see any items in columns other than "Ready for release" tagged with the current milestone, remove that milestone tag. These items didn't make it into the release. @@ -319,7 +311,6 @@ Immediately after publishing a new release, we close out the associated GitHub i 12. Announce that `main` is unfrozen and the milestone has been closed in #help-engineering. ## Oncall rotation - - [The rotation](#the-rotation) - [Responsibilities](#responsibilities) - [Clearing the plate](#clearing-the-plate) @@ -328,7 +319,6 @@ Immediately after publishing a new release, we close out the associated GitHub i - [Handoff](#handoff) ### The rotation - See [the internal Google Doc](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) for the engineers in the rotation. Fleet team members can also subscribe to the [shared calendar](https://calendar.google.com/calendar/u/0?cid=Y181MzVkYThiNzMxMGQwN2QzOWEwMzU0MWRkYzc5ZmVhYjk4MmU0NzQ1ZTFjNzkzNmIwMTAxOTllOWRmOTUxZWJhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20) for calendar events. @@ -338,14 +328,12 @@ New engineers are added to the oncall rotation by their manager after they have > The oncall rotation may be adjusted with approval from the EMs of any product groups affected. Any changes should be made before the start of the sprint so that capacity can be planned accordingly. ### Responsibilities - - [Second-line response](#second-line-response) - [PR reviews](#pr-reviews) - [Customer success meetings](#customer-success-meetings) - [Improve documentation](#improve-documentation) #### Second-line response - The oncall engineer is a second-line responder to questions raised by customers and community members. The community contact (Kathy) is responsible for the first response to GitHub issues, pull requests, and Slack messages in the [#fleet channel](https://osquery.slack.com/archives/C01DXJL16D8) of osquery Slack, and other public Slacks. Kathy and Zay are responsible for the first response to messages in private customer Slack channels. @@ -355,7 +343,6 @@ We respond within 1-hour (during business hours) for interactions and ask the on > Response SLAs help us measure and guarantee the responsiveness that a customer [can expect](https://fleetdm.com/handbook/company#values) from Fleet. But SLAs aside, when a Fleet customer has an emergency or other time-sensitive situation ongoing, it is Fleet's priority to help them find them a solution quickly. #### PR reviews - PRs from Fleeties are reviewed by auto-assignment of codeowners, or by selecting the group or reviewer manually. PRs should remain in draft until they are ready to be reviewed for final approval, this means the feature is complete with tests already added. This helps keep our active list of PRs relevant and focused. It is ok and encouraged to request feedback while a PR is in draft to engage the team. @@ -363,17 +350,14 @@ PRs should remain in draft until they are ready to be reviewed for final approva All PRs from the community are routed through the oncall engineer. For documentation changes, the community contact ([Kathy](https://github.com/ksatter)) is assigned by the oncall engineer. For code changes, if the oncall engineer has the knowledge and confidence to review, they should do so. Otherwise, they should request a review from an engineer with the appropriate domain knowledge. It is the oncall engineer's responsibility to monitor community PRs and make sure that they are moved forward (either by review with feedback or merge). #### Customer success meetings - The oncall engineer is encouraged to attend some of the customer success meetings during the week. Post a message to the #g-cx Slack channel requesting invitations to upcoming meetings. This has a dual purpose of providing more context for how our customers use Fleet. The engineer should actively participate and provide input where appropriate (if not sure, please ask your manager or organizer of the call). #### Improve documentation - The oncall engineer is asked to read, understand, test, correct, and improve at least one doc page per week. Our goal is to 1, ensure accuracy and verify that our deployment guides and tutorials are up to date and work as expected. And 2, improve the readability, consistency, and simplicity of our documentation – with empathy towards first-time users. See [Writing documentation](https://fleetdm.com/handbook/marketing#writing-documentation) for writing guidelines, and don't hesitate to reach out to [#g-digital-experience](https://fleetdm.slack.com/archives/C01GQUZ91TN) on Slack for writing support. A backlog of documentation improvement needs is kept [here](https://github.com/orgs/fleetdm/projects/40/views/10). ### Clearing the plate - Engineering managers are asked to be aware of the [oncall rotation](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) and schedule a light workload for engineers while they are oncall. While it varies week to week considerably, the oncall responsibilities can sometimes take up a substantial portion of the engineer's time. The remaining time after fulfilling the responsibilities of oncall is free for the engineer to choose their own path. Please choose something relevant to your work or Fleet's goals to focus on. If unsure, feel free to speak with your manager. @@ -389,11 +373,9 @@ Some ideas: At the end of your oncall shift, you will be asked to share about how you spent your time. ### How to reach the oncall engineer - Oncall engineers do not need to actively monitor Slack channels, except when called in by the Community or Customer teams. Members of those teams are instructed to `@oncall` in `#help-engineering` to get the attention of the oncall engineer to continue discussing any issues that come up. In some cases, the Community or Customer representative will continue to communicate with the requestor. In others, the oncall engineer will communicate directly (team members should use their judgment and discuss on a case-by-case basis how to best communicate with community members and customers). ### Escalations - When the oncall engineer is unsure of the answer, they should follow this process for escalation. To achieve quick "first-response" times, you are encouraged to say something like "I don't know the answer and I'm taking it back to the team," or "I think X, but I'm confirming that with the team (or by looking in the code)." @@ -405,7 +387,6 @@ How to escalate: 2. Create a new thread in the [#help-engineering channel](https://fleetdm.slack.com/archives/C019WG4GH0A), tagging `@zwass` and provide the information turned up in your research. Please include possibly relevant links (even if you didn't find what you were looking for there). Zach will work with you to craft an appropriate answer or find another team member who can help. ### Handoff - The oncall engineer changes each week on Wednesday. A Slack reminder should notify the oncall of the handoff. Please do the following: @@ -430,7 +411,6 @@ In the Slack reminder thread, the oncall engineer includes their retrospective. 3. How did you spend the rest of your oncall week? This is a chance to demo or share what you learned. ## Incident postmortems - At Fleet, we take customer incidents very seriously. After working with customers to resolve issues, we will conduct an internal postmortem to determine any documentation or coding changes to prevent similar incidents from happening in the future. Why? We strive to make Fleet the best osquery management platform globally, and we sincerely believe that starts with sharing lessons learned with the community to become stronger together. At Fleet, we do postmortem meetings for every production incident, whether it's a customer's environment or on fleetdm.com. @@ -440,11 +420,9 @@ At Fleet, we do postmortem meetings for every production incident, whether it's - [Postmortem action items](#postmortem-action-items) ### Postmortem document - Before running the postmortem meeting, copy this [Postmortem Template](https://docs.google.com/document/d/1Ajp2LfIclWfr4Bm77lnUggkYNQyfjePiWSnBv1b1nwM/edit?usp=sharing) document and populate it with some initial data to enable a productive conversation. ### Postmortem meeting - Invite all stakeholders, typically the team involved and QA representatives. Follow the document topic by topic. Keep the goal in mind which is to take action items for addressing the root cause and making sure a similar incident will not happen again. @@ -454,11 +432,9 @@ Distinguish between the root cause of the bug, which by that time was solved and [Example Finished Document](https://docs.google.com/document/d/1YnETKhH9R7STAY-PaFnPy2qxhNht2EAFfkv-kyEwebQ/edit?usp=share_link) ### Postmortem action items - Each action item will have an owner that will be responsible for creating a Github issue promptly after the meeting. This Github issue should be prioritized with the relevant PM/EM. ## Outages - At Fleet, we consider an outage to be a situation where new features or previously stable features are broken or unusable. - Occurences of outages are tracked in the [Outages](https://docs.google.com/spreadsheets/d/1a8rUk0pGlCPpPHAV60kCEUBLvavHHXbk_L3BI0ybME4/edit#gid=0) spreadsheet. @@ -466,15 +442,12 @@ At Fleet, we consider an outage to be a situation where new features or previous - Fleet stresses the critical importance of avoiding outages because they make customers' lives worse instead of better. ## Scaling Fleet - Fleet, as a Go server, scales horizontally very well. It’s not very CPU or memory intensive. However, there are some specific gotchas to be aware of when implementing new features. Visit our [scaling Fleet page](https://fleetdm.com/handbook/engineering/scaling-fleet) for tips on scaling Fleet as efficiently and effectively as possible. ## Load testing - The [load testing page](https://fleetdm.com/handbook/engineering/load-testing) outlines the process we use to load test Fleet, and contains the results of our semi-annual load test. ## Version support - To provide the most accurate and efficient support, Fleet will only target fixes based on the latest released version. In the current version fixes, Fleet will not backport to older releases. Community version supported for bug fixes: **Latest version only** @@ -486,7 +459,6 @@ Premium version supported for bug fixes: **Latest version only** Premium support for support/troubleshooting: **All versions** ## Reviewing PRs from the community - If you're assigned a community pull request for review, it is important to keep things moving for the contributor. The goal is to not go more than one business day without following up with the contributor. A PR should be merged if: @@ -514,7 +486,6 @@ For PRs that will not be merged: - Close the PR. ### Merging community PRs - When merging a pull request from a community contributor: - Ensure that the checklist for the submitter is complete. @@ -524,7 +495,6 @@ When merging a pull request from a community contributor: - Share the merged PR with the team in the #help-promote channel of Fleet Slack to be publicized on social media. Those who contribute to Fleet and are recognized for their contributions often become great champions for the project. ## Changes to tables' schema - Whenever a PR is proposed for making changes to our [tables' schema](https://fleetdm.com/tables/screenlock)(e.g. to schema/tables/screenlock.yml), it also has to be reflected in our osquery_fleet_schema.json file. The website team will [periodically](https://fleetdm.com/handbook/marketing/website-handbook#rituals) update the json file with the latest changes. If the changes should be deployed sooner, you can generate the new json file yourself by running these commands: @@ -538,13 +508,11 @@ cd website > If a table is added to our ChromeOS extension but it does not exist in osquery or if it is a table added by fleetd, add a note that mentions it. As in this [example](https://github.com/fleetdm/fleet/blob/e95e075e77b683167e86d50960e3dc17045e3c44/schema/tables/mdm.yml#L2). ## Quality - - [Human-oriented QA](#human-oriented-qa) - [Finding bugs](#finding-bugs) - [Outages](#outages) ### Human-oriented QA - Fleet uses a human-oriented quality assurance (QA) process to make sure the product meets the standards of users and organizations. Automated tests are important, but they can't catch everything. Many issues are hard to notice until a human looks empathetically at the user experience, whether in the user interface, the REST API, or the command line. @@ -562,7 +530,6 @@ The goal of quality assurance is to identify corrections and improvements before - Perceived data freshness ### Finding bugs - To try Fleet locally for QA purposes, run `fleetctl preview`, which defaults to running the latest stable release. To target a different version of Fleet, use the `--tag` flag to target any tag in [Docker Hub](https://hub.docker.com/r/fleetdm/fleet/tags?page=1&ordering=last_updated), including any git commit hash or branch name. For example, to QA the latest code on the `main` branch of fleetdm/fleet, you can run: `fleetctl preview --tag=main`. @@ -574,11 +541,9 @@ For each bug found, please use the [bug report template](https://github.com/flee For unreleased bugs in an active sprint, a new bug is created with the `~unreleased bug` label. The `:release` label and associated product group label is added, and the engineer responsible for the feature is assigned. If QA is unsure who the bug should be assigned to, it is assigned to the EM. Fixing the bug becomes part of the story. ### Debugging - You can read our guide to diagnosing issues in Fleet on the [debugging page](https://fleetdm.com/handbook/engineering/debugging). ## Bug process - - [Bug states](#bug-states) - [Finding bugs](#finding-bugs) - [Outages](#outages) @@ -632,18 +597,27 @@ If the bug does not meet the criteria of a critical bug, the EM will determine i When fixing the bug, if the proposed solution requires changes that would affect the user experience (UI, API, or CLI), notify the EM and PM to align on the acceptability of the change. +Engineering teams coordinate on bug fixes with the product team during the joint sprint kick-off review. If one team is at capacity and a bug needs attention, another team can step in to assist by following these steps: + +For MDM support on CX bugs: +- Remove the `#g-cx` label and add `#g-mdm` label. +- Add `~assisting g-cx` to clarify the bug’s origin. + +For CX support on MDM bugs: +- Remove the `#g-mdm` label and add `#g-cx` label. +- Add `~assisting g-mdm` to clarify the bug’s origin. + + Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizing-improvements) into a release within six weeks. If a bug is not prioritized in the current release, and it is not prioritized in the next release, it is removed from the "Sprint backlog" and placed back in the "Product drafting" column with the `:product` label. Product will determine if the bug should be closed as accepted behavior, or if further drafting is necessary. #### Awaiting QA Bugs will be verified as fixed by QA when they are placed in the "Awaiting QA" column of the relevant product group's sprint board. If the bug is verified as fixed, it is moved to the "Ready for release" column of the sprint board. Otherwise, the remaining issues are noted in a comment, and it is moved back to the "In progress" column of the sprint board. ### All bugs - - [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+is%3Aopen+label%3Abug). - [See on ZenHub](https://app.zenhub.com/workspaces/-bugs-647f6d382e171b003416f51a/board). #### Bugs opened this week - This filter returns all "bug" issues opened after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+label%3Abug+created%3A%3E%3DREPLACE_ME_YYYY-MM-DD). #### Bugs closed this week @@ -651,7 +625,6 @@ This filter returns all "bug" issues opened after the specified date. Simply rep This filter returns all "bug" issues closed after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on Github](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+is%3Aclosed+label%3Abug+closed%3A%3E%3DREPLACE_ME_YYYY-MM-DD). ## Release testing - - [Release blockers](#release-blockers) - [Critical bugs](#critical-bugs) @@ -662,11 +635,9 @@ When a critical bug is found, the Fleetie who labels the bug as critical is resp All unreleased bugs are addressed before publishing a release. Released bugs that are not critical may be addressed during the next release per the standard [bug process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#bug-process). ### Release blockers - Product may add the `~release blocker` label to user stories to indicate that the story must be completed to publish the next version of Fleet. Bugs are never labeled as release blockers. ### Critical bugs - A critical bug is a bug with the `~critical bug` label. A critical bug is defined as behavior that: * Blocks the normal use of a workflow * Prevents upgrades to Fleet @@ -674,7 +645,6 @@ A critical bug is a bug with the `~critical bug` label. A critical bug is define * Introduces a security vulnerability #### Critical bug notification process - We need to inform customers and the community about critical bugs immediately so they don’t trigger it themselves. When a bug meeting the definition of critical is found, the bug finder is responsible for raising an alarm. Raising an alarm means pinging @here in the #help-product channel with the filed bug. @@ -690,7 +660,6 @@ If a quick fix workaround exists, that should be communicated as well for those When a critical bug is identified, we will then follow the patch release process in [our documentation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases). ## Measurement - We track the success of this process by observing the throughput of issues through the system and identifying where buildups (and therefore bottlenecks) are occurring. The metrics are: * Number of bugs opened this week @@ -705,7 +674,6 @@ In the above process, any reference to "product" refers to: Mo Zhu, Head of Prod In the above process, any reference to "QA" refers to: Reed Haynes, Product Quality Specialist ## Infrastructure - - [Infrastructure links](#infrastructure-links) - [Best practices](#best-practices) - [24/7 on-call](#24-7-on-call) @@ -713,7 +681,6 @@ In the above process, any reference to "QA" refers to: Reed Haynes, Product Qual The [infrastructure product group](https://fleetdm.com/handbook/company/development-groups#infrastructure-group) is responsible for deploying, supporting, and maintaining all Fleet-managed cloud deployments. ### Infrastructure links - The following are quick links to infrastructure-related README files in both public and private repos that can be used as a quick reference for infrastructure-related code: - [Sandbox](https://github.com/fleetdm/fleet/blob/main/infrastructure/sandbox/readme.md) @@ -724,7 +691,6 @@ The following are quick links to infrastructure-related README files in both pub - [VPN](https://github.com/fleetdm/confidential/blob/main/vpn/README.md) ### Best practices - The infrastructure team follows industry best practices when designing and deploying infrastructure. For containerized infrastructure, Google has created a [reference document](https://cloud.google.com/architecture/best-practices-for-operating-containers) as an ideal reference for these practices. Many of these practices must be implemented in Fleet directly, and engineering will work to ensure that feature implementation follows these practices. The infrastructure team will make itself available to provide guidance as needed. If a feature is not compatible with these practices, an issue will be created with a request to correct the implementation. @@ -768,11 +734,9 @@ The information needed to evaluate and potentially fix any issues is documented When an infrastructure on-call engineer is out of the office, Zach Wasserman will serve as a backup to on-call in #help-p1. All absences must be communicated in advance to Luke Heath and Zach Wasserman. ## Accounts - Engineering is responsible for managing third-party accounts required to support engineering infrastructure. ### Apple developer account - We use the official Fleet Apple developer account to notarize installers we generate for Apple devices. Whenever Apple releases new terms of service, we are unable to notarize new packages until the new terms are accepted. When this occurs, we will begin receiving the following error message when attempting to notarize packages: "You must first sign the relevant contracts online." To resolve this error, follow the steps below. @@ -787,37 +751,21 @@ When this occurs, we will begin receiving the following error message when attem 5. Accept the new terms of service. -## Rituals -The following rituals are engaged in by the directly responsible individual (DRI) and at the frequency specified for the ritual. +## Responsibilities -| Ritual | Frequency | Description | DRI | -| :---------------------------- | :------------------ | :------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| Pull request review | Daily | Engineers go through pull requests for which their review has been requested. | Luke Heath | -| Engineering group discussions | Weekly | See "Group Weeklies". | Zach Wasserman | -| Oncall handoff | Weekly | Hand off the oncall engineering responsibilities to the next oncall engineer. | Luke Heath | -| Vulnerability alerts (fleetdm.com) | Weekly | Review and remediate or dismiss [vulnerability alerts](https://github.com/fleetdm/fleet/security) for the fleetdm.com codebase on GitHub. | Eric Shaw | -| Vulnerability alerts (frontend) | Weekly | Review and remediate or dismiss [vulnerability alerts](https://github.com/fleetdm/fleet/security) for the Fleet frontend codebase (and related JS) on GitHub. | Zach Wasserman | -| Vulnerability alerts (backend) | Weekly | Review and remediate or dismiss [vulnerability alerts](https://github.com/fleetdm/fleet/security) for the Fleet backend codebase (and all Go code) on GitHub. | Zach Wasserman | -| Freeze ritual | Every three weeks | Go through [the process of freezing](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases) the `main` branch to prepare for the next release. | Luke Heath | -| Release ritual | Every three weeks | Go through [the process of releasing](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md) the next iteration of Fleet. | Luke Heath | -| Create patch release branch | Every patch release | Go through the process of [creating a patch release](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases) branch, cherry picking commits, and pushing the branch to github.com/fleetdm/fleet. | Luke Heath | -| Bug review | Weekly | Review bugs that are in QA's inbox. | Reed Haynes | -| QA report | Every three weeks | Every release cycle, on the Monday of release week, the DRI for the release ritual is updated on status of testing. | Reed Haynes | -| Release QA | Every three weeks | Every release cycle, by end of day Friday of release week, all issues move to "Ready for release" on the #g-mdm and #g-cx sprint boards. | Reed Haynes | +TODO -## Slack channels +> work in progress, contributions welcome, please just make only one small change at a time per PR. See https://fleetdm.com/handbook/company/leadership#vision-for-dept-handbook-pages for info -The following [Slack channels are maintained](https://fleetdm.com/handbook/company#group-slack-channels) by this group: +## Rituals + -| Slack channel | [DRI](https://fleetdm.com/handbook/company#why-group-slack-channels) | -| :------------------- | :------------------------------------------------------------------- | -| `#help-engineering` | Zach Wasserman | -| `#g-mdm` | George Karr | -| `#g-customer-experience` | Sharon Katz | -| `#g-infra` | Luke Heath | -| `#help-qa` | Reed Haynes | -| `#_pov-environments` | Ben Edwards | + +#### Stubs + +##### Scrum boards +Please see 📖[handbook/company/engineering#contact-us](https://fleetdm.com/handbook/company/engineering#contact-us) diff --git a/handbook/engineering/engineering.rituals.yml b/handbook/engineering/engineering.rituals.yml new file mode 100644 index 000000000000..2ee8b259d71d --- /dev/null +++ b/handbook/engineering/engineering.rituals.yml @@ -0,0 +1,98 @@ +- + task: "Pull request review" # Title that will actually show in rituals table + startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday + frequency: "Daily" # must be supported by + description: "Engineers go through pull requests for which their review has been requested." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" + moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table + dri: "lukeheath" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) + #autoIssue: # Enables automation of GitHub issues + #labels: [ "#g-cx" ] # label to be applied to issue +- + task: "Engineering group discussions" + startedOn: "2023-08-09" + frequency: "Daily" + description: "Engineers go through pull requests for which their review has been requested." + moreInfoUrl: + dri: "lukeheath" +- + task: "Oncall handoff" + startedOn: "2023-08-09" + frequency: "Weekly" + description: "Hand off the oncall engineering responsibilities to the next oncall engineer." + moreInfoUrl: + dri: "lukeheath" +- + task: "Vulnerability alerts (fleetdm.com)" + startedOn: "2023-08-09" + frequency: "Weekly" + description: "Review and remediate or dismiss [vulnerability alerts](https://github.com/fleetdm/fleet/security) for the fleetdm.com codebase on GitHub." + moreInfoUrl: + dri: "eashaw" +- + task: "Vulnerability alerts (frontend)" + startedOn: "2023-08-09" + frequency: "Weekly" + description: "Review and remediate or dismiss [vulnerability alerts](https://github.com/fleetdm/fleet/security) for the Fleet frontend codebase (and related JS) on GitHub." + moreInfoUrl: + dri: "zwass" +- + task: "Vulnerability alerts (backend)" + startedOn: "2023-08-09" + frequency: "Weekly" + description: "Review and remediate or dismiss [vulnerability alerts](https://github.com/fleetdm/fleet/security) for the Fleet backend codebase (and all Go code) on GitHub." + moreInfoUrl: + dri: "zwass" +- + task: "Freeze ritual" + startedOn: "2023-08-09" + frequency: "Triweekly" + description: "Go through the process of freezing the `main` branch to prepare for the next release." + moreInfoUrl: "https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases" + dri: "lukeheath" +- + task: "Release ritual" + startedOn: "2023-08-09" + frequency: "Triweekly" + description: "Go through the process of releasing the next iteration of Fleet." + moreInfoUrl: "https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md" + dri: "lukeheath" +- + task: "Create patch release branch" + startedOn: "2023-08-09" + frequency: "Every patch release" + description: "Go through the process of creating a patch release branch, cherry picking commits, and pushing the branch to github.com/fleetdm/fleet." + moreInfoUrl: "https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases" + dri: "lukeheath" +- + task: "Bug review" + startedOn: "2023-08-09" + frequency: "Weekly" + description: "Review bugs that are in QA's inbox." + moreInfoUrl: + dri: "xpkoala" +- + task: "QA report" + startedOn: "2023-08-09" + frequency: "Triweekly" + description: "Every release cycle, on the Monday of release week, the DRI for the release ritual is updated on status of testing." + moreInfoUrl: + dri: "xpkoala" +- + task: "Release QA" + startedOn: "2023-08-09" + frequency: "Triweekly" + description: "Every release cycle, by end of day Friday of release week, all issues move to Ready for release on the #g-mdm and #g-cx sprint boards." + moreInfoUrl: + dri: "xpkoala" + + + + + + + + + + + + diff --git a/handbook/marketing/README.md b/handbook/marketing/README.md index 8cd02dd130fc..84fcd33b613c 100644 --- a/handbook/marketing/README.md +++ b/handbook/marketing/README.md @@ -36,7 +36,7 @@ Fleet's community programs are rooted in several areas; created to nurture commu ### Social media Fleet's largest asset is our user community, the people actually using Fleet. Public conversations on social media create valuable opportunities for contributors to answer technical questions and collect feedback. -Fleet [does not self-promote](https://www.audible.com/pd/The-Impact-Equation-Audiobook/B00AR1VFBU). (Great brands are [magnanimous](https://en.wikipedia.org/wiki/Magnanimity).) +Fleet [does not self-promote](https://www.audible.com/pd/The-Impact-Equation-Audiobook/B00AR1VFBU). (Great brands are [magnanimous](https://en.wikipedia.org/wiki/Magnanimity).) In fact, conversations are already happening in our social spaces that open up opportunities for Fleet to [engage with the community](https://fleetdm.com/handbook/marketing#engage-with-the-community). Here are some topics for social media posts: - Fleet the product @@ -47,10 +47,10 @@ Here are some topics for social media posts: - Industry news about device management - Upcoming events, interviews, and podcasts - ### Ads Fleet uses advertising to spread awareness through a broader audience and foster greater engagement within user communities. The more people actively using Fleet, or contributing, the better Fleet will be. + ### Events It's important for Fleet to engage at events. This provides an opportunity to directly engage with potential users and contributors, build relationships, gather feedback, and create a stronger sense of community and trust. @@ -118,6 +118,19 @@ Any changes to the current running ads visible to a user, including designs, key 2. Compare existing ads against the newly proposed ad within the corresponding ad platform. ([Google Ads](https://ads.google.com/home/), [LinkedIn Campaign Manager](https://www.linkedin.com/campaignmanager/), etc.) 3. If your change is approved, Field Marketer makes changes and creates a calendar reminder to check performance two weeks from the date changes were made. + +### Engage with the community +Public conversations on social media create valuable opportunities for contributors to answer technical questions and collect feedback. + +Here are some links that filter relevant conversations on each platform: +- [LinkedIn](https://www.linkedin.com/search/results/content/?datePosted=%22past-week%22&keywords=osquery%20OR%20%22fleet%20device%20management%22%20OR%20%22fleetdm%22%20OR%20%22github.com%2Ffleetdm%2Ffleet%22%20OR%20%22fleetdm.com%22&origin=FACETED_SEARCH&sid=oxR) +- [Twitter](https://twitter.com/search?q=%22osquery%22%20OR%20%22github.com%2Fosquery%2Fosquery%22%20OR%20%22github.com%2Ffleetdm%2Ffleet%22%20OR%20%22github.com%2Fkolide%2Ffleet%22%20OR%20%22fleetdm%22%20OR%20%22fleet%20device%20management%22%20OR%20%22nanomdm%22%20OR%20%22micromdm%22%20OR%20%22swiftDialog%22&src=typed_query&f=live) + +1. Find conversations that are relevant to Fleet on both LinkedIn and Twitter +2. Reply to threads looking for solutions Fleet can solve with helpful information. If additional information is needed, find help in [#help-engineering](https://fleetdm.slack.com/archives/C019WG4GH0A) for accurate information. +3. Leave a like on threads and posts that are interesting, cool, celebratory, funny, etc. within our communities. +4. If a post is helpful to our audience, reshare it. + ### Book an event For an event to be considered, booked, and scheduled, we follow the event issue template. @@ -129,6 +142,7 @@ Once approval has been received, move the event into the "🗓 Planned events" c ### Review ongoing events Check the "🗓 Planned events" column in [#g-marketing board](https://app.zenhub.com/workspaces/g-marketing-64e6c8e2d35c7f001a457b7f/board) and continue to work through steps in each event's issue. + ## Rituals diff --git a/handbook/marketing/marketing.rituals.yml b/handbook/marketing/marketing.rituals.yml index fafb2e96c378..2675bdb21229 100644 --- a/handbook/marketing/marketing.rituals.yml +++ b/handbook/marketing/marketing.rituals.yml @@ -1,35 +1,36 @@ -- - task: "Prioritize for next sprint" # Title that will actually show in rituals table +- task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "mikermcneil" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) - autoIssue: # Enables automation of GitHub issues - labels: [ "#g-marketing" ] # label to be applied to issue -- - task: "Optimize ads" + autoIssue: # Enables automation of GitHub issues + labels: ["#g-marketing"] # label to be applied to issue +- task: "Optimize ads" startedOn: "2023-09-25" frequency: "Biweekly" description: "Apply an ad variation to each ad every two weeks." moreInfoUrl: "https://fleetdm.com/handbook/marketing#optimize-ads-through-experimentation" dri: "DrewBakerfdm" -- - task: "Process pending swag requests from the website" # Title that will actually show in rituals table +- task: "Process pending swag requests from the website" # Title that will actually show in rituals table startedOn: "2023-09-20" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Weekly" # must be supported by description: "Complete draft orders." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/marketing#process-pending-swag-requests-from-the-website" #URL used to highlight "description:" test in table dri: "drewbakerfdm" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) -- - task: "Review ongoing events" +- task: "Engage with the community" + startedOn: "2023-09-20" + frequency: "Daily" + description: "Find relevant conversations with the community and contribute" + moreInfoUrl: "https://fleetdm.com/handbook/marketing#engage-with-the-community" + dri: "drewbakerfdm" +- task: "Review ongoing events" startedOn: "2023-10-02" frequency: "Daily" description: "Check 🗓️ Planned events and complete steps in each issue" moreInfoUrl: "https://fleetdm.com/handbook/marketing#review-ongoing-events" dri: "drewbakerfdm" -- - task: "Book an event" +- task: "Book an event" startedOn: "2023-10-02" frequency: "Weekly" description: "Populate 🗓️ Ideas for future events" diff --git a/handbook/product/README.md b/handbook/product/README.md index 4471388fe05d..30eaf8047786 100644 --- a/handbook/product/README.md +++ b/handbook/product/README.md @@ -77,9 +77,9 @@ For external contributors: please consider opening an issue with reference scree Once the draft has been approved, it moves to the "Settled" column on the drafting board. -Before assigning an engineering manager for [estimation](https://fleetdm.com/handbook/engineering#sprint-ceremonies), the product team should ensure the product section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete. +Before assigning an engineering manager to [estimate](https://fleetdm.com/handbook/engineering#sprint-ceremonies) a user story, the product designer ensures the product section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete. -> The story's designer is responsible for ensuring the checklist has been completed, the requirements section is consistent with the Figma, and the group engineering manager has been assigned. +Once a bug has gone through design and is considered "Settled", the designer removes the `:product` label and moves the issue to the 'To be scheduled' column on the "Bugs" board. The product manager then prioritizes the bug into the "Sprint backlog" and assigns the group engineering manager. Learn https://fleetdm.com/handbook/company/development-groups#making-changes diff --git a/infrastructure/loadtesting/terraform/readme.md b/infrastructure/loadtesting/terraform/readme.md index bf25be2b06e0..e46cd0be42d2 100644 --- a/infrastructure/loadtesting/terraform/readme.md +++ b/infrastructure/loadtesting/terraform/readme.md @@ -1,7 +1,7 @@ ## Terraform for Loadtesting Environment The interface into this code is designed to be minimal. -If you require changes beyond whats described here, contact @zwinnerman-fleetdm. +If you require changes beyond whats described here, contact #g-infra. ### Deploying your code to the loadtesting environment diff --git a/orbit/changes/12842-orbit-bitlocker-management b/orbit/changes/12842-orbit-bitlocker-management new file mode 100644 index 000000000000..97d7e6fe1ec5 --- /dev/null +++ b/orbit/changes/12842-orbit-bitlocker-management @@ -0,0 +1 @@ +* Adding support to manage Bitlocker operations through Orbit notifications diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 3b91e700f91c..3e9f56df12ff 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -622,6 +622,7 @@ func main() { const ( renewEnrollmentProfileCommandFrequency = time.Hour windowsMDMEnrollmentCommandFrequency = time.Hour + windowsMDMBitlockerCommandFrequency = time.Hour ) configFetcher := update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL) configFetcher = update.ApplyRunScriptsConfigFetcherMiddleware(configFetcher, c.Bool("enable-scripts"), orbitClient) @@ -638,6 +639,7 @@ func main() { configFetcher = update.ApplySwiftDialogDownloaderMiddleware(configFetcher, updateRunner) case "windows": configFetcher = update.ApplyWindowsMDMEnrollmentFetcherMiddleware(configFetcher, windowsMDMEnrollmentCommandFrequency, orbitHostInfo.HardwareUUID, orbitClient) + configFetcher = update.ApplyWindowsMDMBitlockerFetcherMiddleware(configFetcher, windowsMDMBitlockerCommandFrequency, orbitClient) } const orbitFlagsUpdateInterval = 30 * time.Second diff --git a/orbit/pkg/bitlocker/bitlocker_management.go b/orbit/pkg/bitlocker/bitlocker_management.go new file mode 100644 index 000000000000..e2105689273a --- /dev/null +++ b/orbit/pkg/bitlocker/bitlocker_management.go @@ -0,0 +1,17 @@ +package bitlocker + +// Encryption Status +type EncryptionStatus struct { + ProtectionStatusDesc string + ConversionStatusDesc string + EncryptionPercentage string + EncryptionFlags string + WipingStatusDesc string + WipingPercentage string +} + +// Volume Encryption Status +type VolumeStatus struct { + DriveVolume string + Status *EncryptionStatus +} diff --git a/orbit/pkg/bitlocker/bitlocker_management_notwindows.go b/orbit/pkg/bitlocker/bitlocker_management_notwindows.go new file mode 100644 index 000000000000..4263ba270e81 --- /dev/null +++ b/orbit/pkg/bitlocker/bitlocker_management_notwindows.go @@ -0,0 +1,19 @@ +//go:build !windows + +package bitlocker + +func GetRecoveryKeys(targetVolume string) (map[string]string, error) { + return nil, nil +} + +func EncryptVolume(targetVolume string) (string, error) { + return "", nil +} + +func DecryptVolume(targetVolume string) error { + return nil +} + +func GetEncryptionStatus() ([]VolumeStatus, error) { + return nil, nil +} diff --git a/orbit/pkg/bitlocker/bitlocker_management_windows.go b/orbit/pkg/bitlocker/bitlocker_management_windows.go new file mode 100644 index 000000000000..4d9bb3683808 --- /dev/null +++ b/orbit/pkg/bitlocker/bitlocker_management_windows.go @@ -0,0 +1,573 @@ +//go:build windows + +package bitlocker + +import ( + "fmt" + "syscall" + + "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + "github.com/scjalliance/comshim" +) + +// Encryption Methods +// https://docs.microsoft.com/en-us/windows/win32/secprov/getencryptionmethod-win32-encryptablevolume +type EncryptionMethod int32 + +const ( + None EncryptionMethod = iota + AES128WithDiffuser + AES256WithDiffuser + AES128 + AES256 + HardwareEncryption + XtsAES128 + XtsAES256 +) + +// Encryption Flags +// https://docs.microsoft.com/en-us/windows/win32/secprov/encrypt-win32-encryptablevolume +type EncryptionFlag int32 + +const ( + EncryptDataOnly EncryptionFlag = 0x00000001 + EncryptDemandWipe EncryptionFlag = 0x00000002 + EncryptSynchronous EncryptionFlag = 0x00010000 + + // Error Codes + ERROR_IO_DEVICE int32 = -2147023779 + FVE_E_EDRIVE_INCOMPATIBLE_VOLUME int32 = -2144272206 + FVE_E_NO_TPM_WITH_PASSPHRASE int32 = -2144272212 + FVE_E_PASSPHRASE_TOO_LONG int32 = -2144272214 + FVE_E_POLICY_PASSPHRASE_NOT_ALLOWED int32 = -2144272278 + FVE_E_NOT_DECRYPTED int32 = -2144272327 + FVE_E_INVALID_PASSWORD_FORMAT int32 = -2144272331 + FVE_E_BOOTABLE_CDDVD int32 = -2144272336 + FVE_E_PROTECTOR_EXISTS int32 = -2144272335 +) + +// DiscoveryVolumeType specifies the type of discovery volume to be used by Prepare. +// https://docs.microsoft.com/en-us/windows/win32/secprov/preparevolume-win32-encryptablevolume +type DiscoveryVolumeType string + +const ( + // VolumeTypeNone indicates no discovery volume. This value creates a native BitLocker volume. + VolumeTypeNone DiscoveryVolumeType = "" + // VolumeTypeDefault indicates the default behavior. + VolumeTypeDefault DiscoveryVolumeType = "" + // VolumeTypeFAT32 creates a FAT32 discovery volume. + VolumeTypeFAT32 DiscoveryVolumeType = "FAT32" +) + +// ForceEncryptionType specifies the encryption type to be used when calling Prepare on the volume. +// https://docs.microsoft.com/en-us/windows/win32/secprov/preparevolume-win32-encryptablevolume +type ForceEncryptionType int32 + +const ( + // EncryptionTypeUnspecified indicates that the encryption type is not specified. + EncryptionTypeUnspecified ForceEncryptionType = 0 + // EncryptionTypeSoftware specifies software encryption. + EncryptionTypeSoftware ForceEncryptionType = 1 + // EncryptionTypeHardware specifies hardware encryption. + EncryptionTypeHardware ForceEncryptionType = 2 +) + +func encryptErrHandler(val int32) error { + switch val { + case ERROR_IO_DEVICE: + return fmt.Errorf("an I/O error has occurred during encryption; the device may need to be reset") + case FVE_E_EDRIVE_INCOMPATIBLE_VOLUME: + return fmt.Errorf("the drive specified does not support hardware-based encryption") + case FVE_E_NO_TPM_WITH_PASSPHRASE: + return fmt.Errorf("a TPM key protector cannot be added because a password protector exists on the drive") + case FVE_E_PASSPHRASE_TOO_LONG: + return fmt.Errorf("the passphrase cannot exceed 256 characters") + case FVE_E_POLICY_PASSPHRASE_NOT_ALLOWED: + return fmt.Errorf("group Policy settings do not permit the creation of a password") + case FVE_E_NOT_DECRYPTED: + return fmt.Errorf("the drive must be fully decrypted to complete this operation") + case FVE_E_INVALID_PASSWORD_FORMAT: + return fmt.Errorf("the format of the recovery password provided is invalid") + case FVE_E_BOOTABLE_CDDVD: + return fmt.Errorf("bitLocker Drive Encryption detected bootable media (CD or DVD) in the computer") + case FVE_E_PROTECTOR_EXISTS: + return fmt.Errorf("key protector cannot be added; only one key protector of this type is allowed for this drive") + default: + return fmt.Errorf("error code returned during encryption: %d", val) + } +} + +///////////////////////////////////////////////////// +// Volume represents a Bitlocker encryptable volume +///////////////////////////////////////////////////// + +type Volume struct { + letter string + handle *ole.IDispatch + wmiIntf *ole.IDispatch + wmiSvc *ole.IDispatch +} + +// bitlockerClose frees all resources associated with a volume. +func (v *Volume) bitlockerClose() { + if v.handle != nil { + v.handle.Release() + } + + if v.wmiIntf != nil { + v.wmiIntf.Release() + } + + if v.wmiSvc != nil { + v.wmiSvc.Release() + } + + comshim.Done() +} + +// encrypt encrypts the volume +// Example: vol.encrypt(bitlocker.XtsAES256, bitlocker.EncryptDataOnly) +// https://docs.microsoft.com/en-us/windows/win32/secprov/encrypt-win32-encryptablevolume +func (v *Volume) encrypt(method EncryptionMethod, flags EncryptionFlag) error { + resultRaw, err := oleutil.CallMethod(v.handle, "Encrypt", int32(method), int32(flags)) + if err != nil { + return fmt.Errorf("encrypt(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("encrypt(%s): %w", v.letter, encryptErrHandler(val)) + } + + return nil +} + +// decrypt encrypts the volume +// Example: vol.decrypt() +// https://learn.microsoft.com/en-us/windows/win32/secprov/decrypt-win32-encryptablevolume +func (v *Volume) decrypt() error { + resultRaw, err := oleutil.CallMethod(v.handle, "Decrypt") + if err != nil { + return fmt.Errorf("decrypt(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("decrypt(%s): %w", v.letter, encryptErrHandler(val)) + } + + return nil +} + +// prepareVolume prepares a new Bitlocker Volume. This should be called BEFORE any key protectors are added. +// Example: vol.prepareVolume(bitlocker.VolumeTypeDefault, bitlocker.EncryptionTypeHardware) +// https://docs.microsoft.com/en-us/windows/win32/secprov/preparevolume-win32-encryptablevolume +func (v *Volume) prepareVolume(volType DiscoveryVolumeType, encType ForceEncryptionType) error { + resultRaw, err := oleutil.CallMethod(v.handle, "PrepareVolume", string(volType), int32(encType)) + if err != nil { + return fmt.Errorf("prepareVolume(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("prepareVolume(%s): %w", v.letter, encryptErrHandler(val)) + } + return nil +} + +// protectWithNumericalPassword adds a numerical password key protector. +// Leave password as a blank string to have one auto-generated by Windows +// https://docs.microsoft.com/en-us/windows/win32/secprov/protectkeywithnumericalpassword-win32-encryptablevolume +func (v *Volume) protectWithNumericalPassword() (string, error) { + var volumeKeyProtectorID ole.VARIANT + ole.VariantInit(&volumeKeyProtectorID) + var resultRaw *ole.VARIANT + var err error + + resultRaw, err = oleutil.CallMethod(v.handle, "ProtectKeyWithNumericalPassword", nil, nil, &volumeKeyProtectorID) + if err != nil { + return "", fmt.Errorf("ProtectKeyWithNumericalPassword(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return "", fmt.Errorf("ProtectKeyWithNumericalPassword(%s): %w", v.letter, encryptErrHandler(val)) + } + + var recoveryKey ole.VARIANT + ole.VariantInit(&recoveryKey) + resultRaw, err = oleutil.CallMethod(v.handle, "GetKeyProtectorNumericalPassword", volumeKeyProtectorID.ToString(), &recoveryKey) + + if err != nil { + return "", fmt.Errorf("GetKeyProtectorNumericalPassword(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return "", fmt.Errorf("GetKeyProtectorNumericalPassword(%s): %w", v.letter, encryptErrHandler(val)) + } + + return recoveryKey.ToString(), nil +} + +// protectWithPassphrase adds a passphrase key protector +// https://docs.microsoft.com/en-us/windows/win32/secprov/protectkeywithpassphrase-win32-encryptablevolume +func (v *Volume) protectWithPassphrase(passphrase string) (string, error) { + var volumeKeyProtectorID ole.VARIANT + ole.VariantInit(&volumeKeyProtectorID) + + resultRaw, err := oleutil.CallMethod(v.handle, "ProtectKeyWithPassphrase", nil, passphrase, &volumeKeyProtectorID) + if err != nil { + return "", fmt.Errorf("protectWithPassphrase(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return "", fmt.Errorf("protectWithPassphrase(%s): %w", v.letter, encryptErrHandler(val)) + } + + return volumeKeyProtectorID.ToString(), nil +} + +// protectWithTPM adds the TPM key protector +// https://docs.microsoft.com/en-us/windows/win32/secprov/protectkeywithtpm-win32-encryptablevolume +func (v *Volume) protectWithTPM(platformValidationProfile *[]uint8) error { + var volumeKeyProtectorID ole.VARIANT + ole.VariantInit(&volumeKeyProtectorID) + var resultRaw *ole.VARIANT + var err error + + if platformValidationProfile == nil { + resultRaw, err = oleutil.CallMethod(v.handle, "ProtectKeyWithTPM", nil, nil, &volumeKeyProtectorID) + } else { + resultRaw, err = oleutil.CallMethod(v.handle, "ProtectKeyWithTPM", nil, *platformValidationProfile, &volumeKeyProtectorID) + } + if err != nil { + return fmt.Errorf("protectKeyWithTPM(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("protectKeyWithTPM(%s): %w", v.letter, encryptErrHandler(val)) + } + + return nil +} + +// getBitlockerStatus returns the current status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getprotectionstatus-win32-encryptablevolume +func (v *Volume) getBitlockerStatus() (*EncryptionStatus, error) { + var ( + conversionStatus int32 + encryptionPercentage int32 + encryptionFlags int32 + wipingStatus int32 + wipingPercentage int32 + precisionFactor int32 = 4 + protectionStatus int32 + ) + + resultRaw, err := oleutil.CallMethod(v.handle, "GetConversionStatus", &conversionStatus, &encryptionPercentage, &encryptionFlags, &wipingStatus, &wipingPercentage, precisionFactor) + if err != nil { + return nil, fmt.Errorf("GetConversionStatus(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return nil, fmt.Errorf("GetConversionStatus(%s): %w", v.letter, encryptErrHandler(val)) + } + + resultRaw, err = oleutil.CallMethod(v.handle, "GetProtectionStatus", &protectionStatus) + if err != nil { + return nil, fmt.Errorf("GetProtectionStatus(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return nil, fmt.Errorf("GetProtectionStatus(%s): %w", v.letter, encryptErrHandler(val)) + } + + // Creating the encryption status struct + encStatus := &EncryptionStatus{ + ProtectionStatusDesc: getProtectionStatusDescription(fmt.Sprintf("%d", protectionStatus)), + ConversionStatusDesc: getConversionStatusDescription(fmt.Sprintf("%d", conversionStatus)), + EncryptionPercentage: intToPercentage(encryptionPercentage), + EncryptionFlags: fmt.Sprintf("%d", encryptionFlags), + WipingStatusDesc: getWipingStatusDescription(fmt.Sprintf("%d", wipingStatus)), + WipingPercentage: intToPercentage(wipingPercentage), + } + + return encStatus, nil +} + +// getProtectorsKeys returns the recovery keys for the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getkeyprotectornumericalpassword-win32-encryptablevolume +func (v *Volume) getProtectorsKeys() (map[string]string, error) { + keys, err := getKeyProtectors(v.handle) + if err != nil { + return nil, fmt.Errorf("getKeyProtectors: %w", err) + } + + recoveryKeys := make(map[string]string) + for _, k := range keys { + var recoveryKey ole.VARIANT + ole.VariantInit(&recoveryKey) + recoveryKeyResultRaw, err := oleutil.CallMethod(v.handle, "GetKeyProtectorNumericalPassword", k, &recoveryKey) + if err != nil { + continue // No recovery key for this protector + } else if val, ok := recoveryKeyResultRaw.Value().(int32); val != 0 || !ok { + continue // No recovery key for this protector + } + recoveryKeys[k] = recoveryKey.ToString() + } + + return recoveryKeys, nil +} + +///////////////////////////////////////////////////// +// Helper functions +///////////////////////////////////////////////////// + +// bitlockerConnect connects to an encryptable volume in order to manage it. +func bitlockerConnect(driveLetter string) (Volume, error) { + comshim.Add(1) + v := Volume{letter: driveLetter} + + unknown, err := oleutil.CreateObject("WbemScripting.SWbemLocator") + if err != nil { + comshim.Done() + return v, fmt.Errorf("createObject: %w", err) + } + defer unknown.Release() + + v.wmiIntf, err = unknown.QueryInterface(ole.IID_IDispatch) + if err != nil { + comshim.Done() + return v, fmt.Errorf("queryInterface: %w", err) + } + serviceRaw, err := oleutil.CallMethod(v.wmiIntf, "ConnectServer", nil, `\\.\ROOT\CIMV2\Security\MicrosoftVolumeEncryption`) + if err != nil { + v.bitlockerClose() + return v, fmt.Errorf("connectServer: %w", err) + } + v.wmiSvc = serviceRaw.ToIDispatch() + + raw, err := oleutil.CallMethod(v.wmiSvc, "ExecQuery", "SELECT * FROM Win32_EncryptableVolume WHERE DriveLetter = '"+driveLetter+"'") + if err != nil { + v.bitlockerClose() + return v, fmt.Errorf("execQuery: %w", err) + } + result := raw.ToIDispatch() + defer result.Release() + + itemRaw, err := oleutil.CallMethod(result, "ItemIndex", 0) + if err != nil { + v.bitlockerClose() + return v, fmt.Errorf("failed to fetch result row while processing BitLocker info: %w", err) + } + v.handle = itemRaw.ToIDispatch() + + return v, nil +} + +// getConversionStatusDescription returns the current status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getconversionstatus-win32-encryptablevolume +func getConversionStatusDescription(input string) string { + switch input { + case "0": + return "FullyDecrypted" + case "1": + return "FullyEncrypted" + case "2": + return "EncryptionInProgress" + case "3": + return "DecryptionInProgress" + case "4": + return "EncryptionPaused" + case "5": + return "DecryptionPaused" + } + + return "Status " + input +} + +// getWipingStatusDescription returns the current wiping status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getconversionstatus-win32-encryptablevolume +func getWipingStatusDescription(input string) string { + switch input { + case "0": + return "FreeSpaceNotWiped" + case "1": + return "FreeSpaceWiped" + case "2": + return "FreeSpaceWipingInProgress" + case "3": + return "FreeSpaceWipingPaused" + } + + return "Status " + input +} + +// getProtectionStatusDescription returns the current protection status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getprotectionstatus-win32-encryptablevolume +func getProtectionStatusDescription(input string) string { + switch input { + case "0": + return "Unprotected" + case "1": + return "Protected" + case "2": + return "Unknown" + } + + return "Status " + input +} + +// intToPercentage converts an int to a percentage string +func intToPercentage(num int32) string { + percentage := float64(num) / 10000.0 + return fmt.Sprintf("%.2f%%", percentage) +} + +// getKeyProtectors returns the key protectors for the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getkeyprotectors-win32-encryptablevolume +func getKeyProtectors(item *ole.IDispatch) ([]string, error) { + kp := []string{} + var keyProtectorResults ole.VARIANT + ole.VariantInit(&keyProtectorResults) + + keyIDResultRaw, err := oleutil.CallMethod(item, "GetKeyProtectors", 3, &keyProtectorResults) + if err != nil { + return nil, fmt.Errorf("unable to get Key Protectors while getting BitLocker info. %s", err.Error()) + } else if val, ok := keyIDResultRaw.Value().(int32); val != 0 || !ok { + return nil, fmt.Errorf("unable to get Key Protectors while getting BitLocker info. Return code %d", val) + } + + keyProtectorValues := keyProtectorResults.ToArray().ToValueArray() + for _, keyIDItemRaw := range keyProtectorValues { + keyIDItem, ok := keyIDItemRaw.(string) + if !ok { + return nil, fmt.Errorf("keyProtectorID wasn't a string") + } + kp = append(kp, keyIDItem) + } + + return kp, nil +} + +// bitsToDrives converts a bit map to a list of drives +func bitsToDrives(bitMap uint32) (drives []string) { + availableDrives := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"} + + for i := range availableDrives { + if bitMap&1 == 1 { + drives = append(drives, availableDrives[i]+":") + } + bitMap >>= 1 + } + + return +} + +func getLogicalVolumes() ([]string, error) { + kernel32, err := syscall.LoadLibrary("kernel32.dll") + if err != nil { + return nil, fmt.Errorf("failed to load kernel32.dll: %v", err) + } + defer syscall.FreeLibrary(kernel32) + + getLogicalDrivesHandle, err := syscall.GetProcAddress(kernel32, "GetLogicalDrives") + if err != nil { + return nil, fmt.Errorf("failed to get procedure address: %v", err) + } + + ret, _, callErr := syscall.SyscallN(uintptr(getLogicalDrivesHandle), 0, 0, 0, 0) + if callErr != 0 { + return nil, fmt.Errorf("syscall to GetLogicalDrives failed: %v", callErr) + } + + return bitsToDrives(uint32(ret)), nil +} + +func getBitlockerStatus(targetVolume string) (*EncryptionStatus, error) { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return nil, fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Get volume status + status, err := vol.getBitlockerStatus() + if err != nil { + return nil, fmt.Errorf("there was an error starting decryption - error: %v", err) + } + + return status, nil +} + +///////////////////////////////////////////////////// +// Bitlocker Management interface implementation +///////////////////////////////////////////////////// + +func GetRecoveryKeys(targetVolume string) (map[string]string, error) { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return nil, fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Get recovery keys + keys, err := vol.getProtectorsKeys() + if err != nil { + return nil, fmt.Errorf("there was an error retreving protection keys: %v", err) + } + + return keys, nil +} + +func EncryptVolume(targetVolume string) (string, error) { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return "", fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Prepare for encryption + if err := vol.prepareVolume(VolumeTypeDefault, EncryptionTypeSoftware); err != nil { + return "", fmt.Errorf("there was an error preparing the volume for encryption - error: %v", err) + } + + // Add a recovery protector + recoveryKey, err := vol.protectWithNumericalPassword() + if err != nil { + return "", fmt.Errorf("there was an error adding a recovery protector - error: %v", err) + } + + // Protect with TPM + if err := vol.protectWithTPM(nil); err != nil { + return "", fmt.Errorf("there was an error protecting with TPM - error: %v", err) + } + + // Start encryption + if err := vol.encrypt(XtsAES256, EncryptDataOnly); err != nil { + return "", fmt.Errorf("there was an error starting encryption - error: %v", err) + } + + return recoveryKey, nil +} + +func DecryptVolume(targetVolume string) error { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Start decryption + if err := vol.decrypt(); err != nil { + return fmt.Errorf("there was an error starting decryption - error: %v", err) + } + + return nil +} + +func GetEncryptionStatus() ([]VolumeStatus, error) { + drives, err := getLogicalVolumes() + if err != nil { + return nil, fmt.Errorf("logical volumen enumeration %v", err) + } + + // iterate drives + var volumeStatus []VolumeStatus + for _, drive := range drives { + status, err := getBitlockerStatus(drive) + if err == nil { + // Skipping errors on purpose + driveStatus := VolumeStatus{ + DriveVolume: drive, + Status: status, + } + volumeStatus = append(volumeStatus, driveStatus) + } + } + + return volumeStatus, nil +} diff --git a/orbit/pkg/update/execwinapi_stub.go b/orbit/pkg/update/execwinapi_stub.go index e4957bc2cd36..50d6a9a4140c 100644 --- a/orbit/pkg/update/execwinapi_stub.go +++ b/orbit/pkg/update/execwinapi_stub.go @@ -9,3 +9,7 @@ func RunWindowsMDMEnrollment(args WindowsMDMEnrollmentArgs) error { func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error { return nil } + +func IsRunningOnWindowsServer() (bool, error) { + return false, nil +} diff --git a/orbit/pkg/update/execwinapi_windows.go b/orbit/pkg/update/execwinapi_windows.go index ca28089dab5c..3c0988a0fcff 100644 --- a/orbit/pkg/update/execwinapi_windows.go +++ b/orbit/pkg/update/execwinapi_windows.go @@ -174,3 +174,17 @@ func generateWindowsMDMAccessTokenPayload(args WindowsMDMEnrollmentArgs) ([]byte pld.Payload.OrbitNodeKey = args.OrbitNodeKey return json.Marshal(pld) } + +// IsRunningOnWindowsServer determines if the process is running on a Windows server. Exported so it can be used across packages. +func IsRunningOnWindowsServer() (bool, error) { + installType, err := readInstallationType() + if err != nil { + return false, err + } + + if strings.ToLower(installType) == "server" { + return true, nil + } + + return false, nil +} diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go index 18076ba92260..e07c6648cc1b 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "time" + "github.com/fleetdm/fleet/v4/orbit/pkg/bitlocker" "github.com/fleetdm/fleet/v4/orbit/pkg/profiles" "github.com/fleetdm/fleet/v4/orbit/pkg/scripts" "github.com/fleetdm/fleet/v4/server/fleet" @@ -397,3 +398,119 @@ func (h *runScriptsConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { } return cfg, err } + +type DiskEncryptionKeySetter interface { + SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error +} + +type execEncryptVolumeFunc func(string) (string, error) + +type windowsMDMBitlockerConfigFetcher struct { + // Fetcher is the OrbitConfigFetcher that will be wrapped. It is responsible + // for actually returning the orbit configuration or an error. + Fetcher OrbitConfigFetcher + + // Frequency is the minimum amount of time that must pass between two + // executions of the windows MDM enrollment attempt. + Frequency time.Duration + + // Bitlocker Operation Results + EncryptionResult DiskEncryptionKeySetter + + // tracks last time the enrollment command was executed + lastEnrollRun time.Time + + // ensures only one script execution runs at a time + mu sync.Mutex + + // for tests, to be able to mock API commands. If nil, will use + // EncryptVolume + execEncryptVolumeFn execEncryptVolumeFunc +} + +func ApplyWindowsMDMBitlockerFetcherMiddleware( + fetcher OrbitConfigFetcher, + frequency time.Duration, + encryptionResult DiskEncryptionKeySetter, +) OrbitConfigFetcher { + return &windowsMDMBitlockerConfigFetcher{ + Fetcher: fetcher, + Frequency: frequency, + EncryptionResult: encryptionResult, + } +} + +// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet +// server set the "EnforceBitLockerEncryption" flag to true, executes the command +// to attempt BitlockerEncryption (or not, if the device is a Windows Server). +func (w *windowsMDMBitlockerConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { + cfg, err := w.Fetcher.GetConfig() + if err == nil && cfg.Notifications.EnforceBitLockerEncryption { + if w.mu.TryLock() { + defer w.mu.Unlock() + + w.attemptBitlockerEncryption(cfg.Notifications) + } + } + + return cfg, err +} + +func (w *windowsMDMBitlockerConfigFetcher) attemptBitlockerEncryption(notifs fleet.OrbitConfigNotifications) { + // do not trigger Bitlocker encryption if running on a Windwos server + isWindowsServer, err := IsRunningOnWindowsServer() + if err != nil { + log.Error().Err(err).Msg("checking if the host is a Windows server") + return + } + + if isWindowsServer { + log.Debug().Msg("device is a Windows Server, encryption is not going to be performed") + return + } + + if time.Since(w.lastEnrollRun) <= w.Frequency { + log.Debug().Msg("skipped encryption process, last run was too recent") + return + } + + // Performing Bitlocker encryption operation against C: volume + + // We are supporting only C: volume for now + targetVolume := "C:" + + // Performing actual encryption + + // Getting Bitlocker encryption mock operation function if any + fn := w.execEncryptVolumeFn + if fn == nil { + // Otherwise, using the real one + fn = bitlocker.EncryptVolume + } + recoveryKey, err := fn(targetVolume) + + // Getting Bitlocker encryption operation error message if any + bitlockerError := "" + if err != nil { + bitlockerError = err.Error() + } + + // Update Fleet Server with encryption result + payload := fleet.OrbitHostDiskEncryptionKeyPayload{ + EncryptionKey: []byte(recoveryKey), + ClientError: bitlockerError, + } + + if err != nil { + log.Error().Err(err).Msg("failed to encrypt the volume") + return + } + + err = w.EncryptionResult.SetOrUpdateDiskEncryptionKey(payload) + if err != nil { + log.Error().Err(err).Msg("failed to send encryption result to Fleet Server") + return + } + + w.lastEnrollRun = time.Now() +} diff --git a/orbit/pkg/update/notifications_test.go b/orbit/pkg/update/notifications_test.go index c4512b12f282..901dcd527630 100644 --- a/orbit/pkg/update/notifications_test.go +++ b/orbit/pkg/update/notifications_test.go @@ -573,3 +573,67 @@ func TestRunScripts(t *testing.T) { require.Contains(t, logBuf.String(), "running scripts [c] succeeded") }) } + +type mockDiskEncryptionKeySetter struct{} + +func (m mockDiskEncryptionKeySetter) SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error { + return nil +} + +func TestBitlockerOperations(t *testing.T) { + var logBuf bytes.Buffer + + oldLog := log.Logger + log.Logger = log.Output(&logBuf) + t.Cleanup(func() { log.Logger = oldLog }) + + var ( + shouldEncrypt = true + shouldReturnError = false + ) + + fetcher := &dummyConfigFetcher{ + cfg: &fleet.OrbitConfig{ + Notifications: fleet.OrbitConfigNotifications{ + EnforceBitLockerEncryption: shouldEncrypt, + }, + }, + } + + enrollFetcher := &windowsMDMBitlockerConfigFetcher{ + Fetcher: fetcher, + Frequency: time.Hour, // doesn't matter for this test + EncryptionResult: mockDiskEncryptionKeySetter{}, + execEncryptVolumeFn: func(string) (string, error) { + if shouldReturnError { + return "", errors.New("error") + } + + return "123456", nil + }, + } + + t.Run("bitlocker encryption is performed", func(t *testing.T) { + shouldEncrypt = true + shouldReturnError = false + cfg, err := enrollFetcher.GetConfig() + require.NoError(t, err) // the dummy fetcher never returns an error + require.Equal(t, fetcher.cfg, cfg) // the bitlocker wrapper properly returns the expected config + }) + + t.Run("bitlocker encryption is not performed", func(t *testing.T) { + shouldEncrypt = false + shouldReturnError = false + cfg, err := enrollFetcher.GetConfig() + require.NoError(t, err) // the dummy fetcher never returns an error + require.Equal(t, fetcher.cfg, cfg) // the bitlocker wrapper properly returns the expected config + }) + + t.Run("bitlocker encryption returns an error", func(t *testing.T) { + shouldEncrypt = true + shouldReturnError = true + cfg, err := enrollFetcher.GetConfig() + require.NoError(t, err) // the dummy fetcher never returns an error + require.Equal(t, fetcher.cfg, cfg) // the bitlocker wrapper properly returns the expected config + }) +} diff --git a/pkg/optjson/optjson.go b/pkg/optjson/optjson.go index ec045070c989..a665b3bb52cc 100644 --- a/pkg/optjson/optjson.go +++ b/pkg/optjson/optjson.go @@ -53,3 +53,42 @@ func (s *String) UnmarshalJSON(data []byte) error { s.Valid = true return nil } + +// Bool represents an optional boolean value. +type Bool struct { + Set bool + Valid bool + Value bool +} + +func SetBool(b bool) Bool { + return Bool{Set: true, Valid: true, Value: b} +} + +func (b Bool) MarshalJSON() ([]byte, error) { + if !b.Valid { + return []byte("null"), nil + } + return json.Marshal(b.Value) +} + +func (b *Bool) UnmarshalJSON(data []byte) error { + // If this method was called, the value was set. + b.Set = true + b.Valid = false + + if bytes.Equal(data, []byte("null")) { + // The key was set to null, blank the value + b.Value = false + return nil + } + + // The key isn't set to null + var v bool + if err := json.Unmarshal(data, &v); err != nil { + return err + } + b.Value = v + b.Valid = true + return nil +} diff --git a/pkg/optjson/optjson_test.go b/pkg/optjson/optjson_test.go index 868963d4a09b..264834a63714 100644 --- a/pkg/optjson/optjson_test.go +++ b/pkg/optjson/optjson_test.go @@ -88,3 +88,84 @@ func TestString(t *testing.T) { } }) } + +func TestBool(t *testing.T) { + t.Run("plain string", func(t *testing.T) { + cases := []struct { + data string + wantErr string + wantRes Bool + marshalAs string + }{ + {`true`, "", Bool{Set: true, Valid: true, Value: true}, `true`}, + {`null`, "", Bool{Set: true, Valid: false, Value: false}, `null`}, + {`123`, "cannot unmarshal number into Go value of type bool", Bool{Set: true, Valid: false, Value: false}, `null`}, + {`{"v": "foo"}`, "cannot unmarshal object into Go value of type bool", Bool{Set: true, Valid: false, Value: false}, `null`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var s Bool + err := json.Unmarshal([]byte(c.data), &s) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, s) + + b, err := json.Marshal(s) + require.NoError(t, err) + require.Equal(t, c.marshalAs, string(b)) + }) + } + }) + + t.Run("struct", func(t *testing.T) { + type N struct { + B2 Bool `json:"b2"` + } + type T struct { + I int `json:"i"` + B Bool `json:"b"` + N N `json:"n"` + } + + cases := []struct { + data string + wantErr string + wantRes T + marshalAs string + }{ + {`{}`, "", T{}, `{"i": 0, "b": null, "n": {"b2": null}}`}, + {`{"x": "nope"}`, "", T{}, `{"i": 0, "b": null, "n": {"b2": null}}`}, + {`{"i": 1, "b": true}`, "", T{I: 1, B: Bool{Set: true, Valid: true, Value: true}}, `{"i": 1, "b": true, "n": {"b2": null}}`}, + {`{"i": 1, "b": null, "n": {}}`, "", T{I: 1, B: Bool{Set: true, Valid: false, Value: false}}, `{"i": 1, "b": null, "n": {"b2": null}}`}, + {`{"i": 1, "b": false, "n": {"b2": true}}`, "", T{I: 1, B: Bool{Set: true, Valid: true, Value: false}, N: N{B2: Bool{Set: true, Valid: true, Value: true}}}, `{"i": 1, "b": false, "n": {"b2": true}}`}, + {`{"i": 1, "b": true, "n": {"b2": null}}`, "", T{I: 1, B: Bool{Set: true, Valid: true, Value: true}, N: N{B2: Bool{Set: true, Valid: false, Value: false}}}, `{"i": 1, "b": true, "n": {"b2": null}}`}, + {`{"i": 1, "b": ""}`, "cannot unmarshal string into Go struct", T{I: 1, B: Bool{Set: true, Valid: false, Value: false}}, `{"i": 1, "b": null, "n": {"b2": null}}`}, + {`{"i": 1, "n": {"b2": 123}}`, "cannot unmarshal number into Go struct", T{I: 1, N: N{B2: Bool{Set: true, Valid: false, Value: false}}}, `{"i": 1, "b": null, "n": {"b2": null}}`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var tt T + err := json.Unmarshal([]byte(c.data), &tt) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, tt) + + b, err := json.Marshal(tt) + require.NoError(t, err) + require.JSONEq(t, c.marshalAs, string(b)) + }) + } + }) +} diff --git a/pkg/rawjson/rawjson.go b/pkg/rawjson/rawjson.go new file mode 100644 index 000000000000..d6bb2189a899 --- /dev/null +++ b/pkg/rawjson/rawjson.go @@ -0,0 +1,55 @@ +package rawjson + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// CombineRoots "concatenates" two JSON objects into a single object. +// +// By virtue of its implementation it: +// +// - Doesn't take into account nested keys +// - Assumes the JSON string is well formed and was marshaled by the standard +// library +func CombineRoots(a, b json.RawMessage) (json.RawMessage, error) { + if err := validate(a); err != nil { + return nil, fmt.Errorf("validating first object: %w", err) + } + + if err := validate(b); err != nil { + return nil, fmt.Errorf("validating second object: %w", err) + } + + emptyObject := []byte{'{', '}'} + if bytes.Equal(a, emptyObject) { + return b, nil + } + if bytes.Equal(b, emptyObject) { + return a, nil + } + + // remove '}' from the first object and add a trailing ',' + combined := append(a[:len(a)-1], ',') + // remove '{' from the second object and combine the two + combined = append(combined, b[1:]...) + return combined, nil +} + +func validate(j json.RawMessage) error { + if len(j) < 2 { + return errors.New("incomplete json object") + } + + if j[0] != '{' || j[len(j)-1] != '}' { + return errors.New("json object must be surrounded by '{' and '}'") + } + + if len(j) > 2 && j[len(j)-2] == ',' { + return errors.New("trailing comma at the end of the object") + } + + return nil +} diff --git a/pkg/rawjson/rawjson_test.go b/pkg/rawjson/rawjson_test.go new file mode 100644 index 000000000000..03b38a3dfa28 --- /dev/null +++ b/pkg/rawjson/rawjson_test.go @@ -0,0 +1,104 @@ +package rawjson + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCombineRoots(t *testing.T) { + tests := []struct { + name string + a json.RawMessage + b json.RawMessage + want json.RawMessage + wantErr string + }{ + { + name: "both empty", + a: []byte("{}"), + b: []byte("{}"), + want: []byte("{}"), + }, + { + name: "first incomplete", + a: []byte("{"), + b: []byte("{}"), + wantErr: "incomplete json object", + }, + { + name: "second incomplete", + a: []byte("{}"), + b: []byte("{"), + wantErr: "incomplete json object", + }, + { + name: "first empty array", + a: []byte{}, + b: []byte("{}"), + wantErr: "incomplete json object", + }, + { + name: "second empty array", + a: []byte("{}"), + b: []byte{}, + wantErr: "incomplete json object", + }, + { + name: "first empty", + a: []byte("{}"), + b: []byte(`{"key":"value"}`), + want: []byte(`{"key":"value"}`), + }, + { + name: "second empty", + a: []byte(`{"key":"value"}`), + b: []byte("{}"), + want: []byte(`{"key":"value"}`), + }, + { + name: "both with data", + a: []byte(`{"key1":"value1"}`), + b: []byte(`{"key2":"value2"}`), + want: []byte(`{"key1":"value1","key2":"value2"}`), + }, + { + name: "first incomplete", + a: []byte(`{"key1":"value1"`), + b: []byte(`{"key2":"value2"}`), + wantErr: "json object must be surrounded by '{' and '}'", + }, + { + name: "second incomplete", + a: []byte(`{"key2":"value2"}`), + b: []byte(`{"key1":"value1"`), + wantErr: "json object must be surrounded by '{' and '}'", + }, + { + name: "first trailing comma", + a: []byte(`{"key1":"value1",}`), + b: []byte(`{"key2":"value2"}`), + wantErr: "trailing comma at the end of the object", + }, + { + name: "second trailing comma", + a: []byte(`{"key1":"value1"}`), + b: []byte(`{"key2":"value2",}`), + wantErr: "trailing comma at the end of the object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CombineRoots(tt.a, tt.b) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.Nil(t, got) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} diff --git a/schema/tables/chrome_extensions.yml b/schema/tables/chrome_extensions.yml index b6a93c28acdb..932c087acfe8 100644 --- a/schema/tables/chrome_extensions.yml +++ b/schema/tables/chrome_extensions.yml @@ -58,7 +58,7 @@ columns: - linux - name: path type: string - description: Defaults to '' on ChromeOS + description: Path to extension folder. Defaults to '' on ChromeOS - name: optional_permissions platforms: - darwin diff --git a/schema/tables/system_info.yml b/schema/tables/system_info.yml index e5c16c16b155..4300141df52d 100644 --- a/schema/tables/system_info.yml +++ b/schema/tables/system_info.yml @@ -57,19 +57,19 @@ columns: - linux - name: hostname type: string - description: For ChromeOS, this is only available if the extension was force-installed by an enterprise policy + description: Network hostname including domain. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: computer_name type: string - description: For ChromeOS, if the extension wasn't force-installed by an enterprise policy this will default to 'ChromeOS' only + description: Friendly computer name (optional). For ChromeOS, if the extension wasn't force-installed by an enterprise policy this will default to 'ChromeOS' only - name: hardware_serial type: string - description: The device's serial number (For chromeos, this is only available if the extension was force-installed by an enterprise policy) + description: The device's serial number. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: hardware_vendor type: string - description: For ChromeOS, this is only available if the extension was force-installed by an enterprise policy + description: Hardware vendor. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: hardware_model type: string - description: For ChromeOS, this is only available if the extension was force-installed by an enterprise policy + description: Hardware model. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: cpu_brand type: string - name: cpu_type diff --git a/server/datastore/mysql/app_configs.go b/server/datastore/mysql/app_configs.go index 0da61f93a33e..c14340fa12ad 100644 --- a/server/datastore/mysql/app_configs.go +++ b/server/datastore/mysql/app_configs.go @@ -213,3 +213,18 @@ func (ds *Datastore) AggregateEnrollSecretPerTeam(ctx context.Context) ([]*fleet } return secrets, nil } + +func (ds *Datastore) getConfigEnableDiskEncryption(ctx context.Context, teamID *uint) (bool, error) { + if teamID != nil && *teamID > 0 { + tc, err := ds.TeamMDMConfig(ctx, *teamID) + if err != nil { + return false, err + } + return tc.EnableDiskEncryption, nil + } + ac, err := ds.AppConfig(ctx) + if err != nil { + return false, err + } + return ac.MDM.EnableDiskEncryption.Value, nil +} diff --git a/server/datastore/mysql/app_configs_test.go b/server/datastore/mysql/app_configs_test.go index 1580d40f257e..66c14b157b9f 100644 --- a/server/datastore/mysql/app_configs_test.go +++ b/server/datastore/mysql/app_configs_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -30,6 +31,7 @@ func TestAppConfig(t *testing.T) { {"AggregateEnrollSecretPerTeam", testAggregateEnrollSecretPerTeam}, {"Defaults", testAppConfigDefaults}, {"Backwards Compatibility", testAppConfigBackwardsCompatibility}, + {"GetConfigEnableDiskEncryption", testGetConfigEnableDiskEncryption}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -309,7 +311,6 @@ func testAppConfigEnrollSecretRoundtrip(t *testing.T, ds *Datastore) { secrets, err = ds.GetEnrollSecrets(context.Background(), nil) require.NoError(t, err) require.Len(t, secrets, 2) - } func testAppConfigEnrollSecretUniqueness(t *testing.T, ds *Datastore) { @@ -431,3 +432,48 @@ func testAggregateEnrollSecretPerTeam(t *testing.T, ds *Datastore) { {TeamID: ptr.Uint(3), Secret: "team_3_secret_1"}, }, agg) } + +func testGetConfigEnableDiskEncryption(t *testing.T, ds *Datastore) { + ctx := context.Background() + defer TruncateTables(t, ds) + + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + require.False(t, ac.MDM.EnableDiskEncryption.Value) + + enabled, err := ds.getConfigEnableDiskEncryption(ctx, nil) + require.NoError(t, err) + require.False(t, enabled) + + // Enable disk encryption for no team + ac.MDM.EnableDiskEncryption = optjson.SetBool(true) + err = ds.SaveAppConfig(ctx, ac) + require.NoError(t, err) + ac, err = ds.AppConfig(ctx) + require.NoError(t, err) + require.True(t, ac.MDM.EnableDiskEncryption.Value) + + enabled, err = ds.getConfigEnableDiskEncryption(ctx, nil) + require.NoError(t, err) + require.True(t, enabled) + + // Create team + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + tm, err := ds.Team(ctx, team1.ID) + require.NoError(t, err) + require.NotNil(t, tm) + require.False(t, tm.Config.MDM.EnableDiskEncryption) + + enabled, err = ds.getConfigEnableDiskEncryption(ctx, &team1.ID) + require.NoError(t, err) + require.False(t, enabled) + + // Enable disk encryption for the team + tm.Config.MDM.EnableDiskEncryption = true + tm, err = ds.SaveTeam(ctx, tm) + require.NoError(t, err) + require.NotNil(t, tm) + require.True(t, tm.Config.MDM.EnableDiskEncryption) +} diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index cb1133ac61fb..1e95d96f1405 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -2082,7 +2082,7 @@ func (ds *Datastore) GetMDMIdPAccount(ctx context.Context, uuid string) (*fleet. return &acct, nil } -func subqueryDiskEncryptionVerifying() (string, []interface{}) { +func subqueryFileVaultVerifying() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2100,7 +2100,7 @@ func subqueryDiskEncryptionVerifying() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionVerified() (string, []interface{}) { +func subqueryFileVaultVerified() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2118,7 +2118,7 @@ func subqueryDiskEncryptionVerified() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionActionRequired() (string, []interface{}) { +func subqueryFileVaultActionRequired() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2138,7 +2138,7 @@ func subqueryDiskEncryptionActionRequired() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionEnforcing() (string, []interface{}) { +func subqueryFileVaultEnforcing() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2168,7 +2168,7 @@ func subqueryDiskEncryptionEnforcing() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionFailed() (string, []interface{}) { +func subqueryFileVaultFailed() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2180,7 +2180,7 @@ func subqueryDiskEncryptionFailed() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionRemovingEnforcement() (string, []interface{}) { +func subqueryFileVaultRemovingEnforcement() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2224,20 +2224,20 @@ FROM hosts h LEFT JOIN host_disk_encryption_keys hdek ON h.id = hdek.host_id WHERE - %s` + h.platform = 'darwin' AND %s` var args []interface{} - subqueryVerified, subqueryVerifiedArgs := subqueryDiskEncryptionVerified() + subqueryVerified, subqueryVerifiedArgs := subqueryFileVaultVerified() args = append(args, subqueryVerifiedArgs...) - subqueryVerifying, subqueryVerifyingArgs := subqueryDiskEncryptionVerifying() + subqueryVerifying, subqueryVerifyingArgs := subqueryFileVaultVerifying() args = append(args, subqueryVerifyingArgs...) - subqueryActionRequired, subqueryActionRequiredArgs := subqueryDiskEncryptionActionRequired() + subqueryActionRequired, subqueryActionRequiredArgs := subqueryFileVaultActionRequired() args = append(args, subqueryActionRequiredArgs...) - subqueryEnforcing, subqueryEnforcingArgs := subqueryDiskEncryptionEnforcing() + subqueryEnforcing, subqueryEnforcingArgs := subqueryFileVaultEnforcing() args = append(args, subqueryEnforcingArgs...) - subqueryFailed, subqueryFailedArgs := subqueryDiskEncryptionFailed() + subqueryFailed, subqueryFailedArgs := subqueryFileVaultFailed() args = append(args, subqueryFailedArgs...) - subqueryRemovingEnforcement, subqueryRemovingEnforcementArgs := subqueryDiskEncryptionRemovingEnforcement() + subqueryRemovingEnforcement, subqueryRemovingEnforcementArgs := subqueryFileVaultRemovingEnforcement() args = append(args, subqueryRemovingEnforcementArgs...) teamFilter := "h.team_id IS NULL" diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 3c9173b63e5e..2d5977334b10 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -782,7 +782,7 @@ func testUpdateHostTablesOnMDMUnenroll(t *testing.T, ds *Datastore) { var hostID uint err = sqlx.GetContext(context.Background(), ds.reader(context.Background()), &hostID, `SELECT id FROM hosts WHERE uuid = ?`, testUUID) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostID, "asdf") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostID, "asdf", "", nil) require.NoError(t, err) key, err := ds.GetHostDiskEncryptionKey(ctx, hostID) @@ -1474,7 +1474,7 @@ func upsertHostCPs( func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) { ctx := context.Background() - checkListHosts := func(status fleet.MacOSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + checkListHosts := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { expectedIDs := []uint{} for _, h := range expected { expectedIDs = append(expectedIDs, h.ID) @@ -1556,7 +1556,7 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "foo") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "foo", "", nil) require.NoError(t, err) res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, nil) require.NoError(t, err) @@ -1596,7 +1596,7 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(1), res.Verified) // hosts[0] now has filevault fully enforced and verified - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[1].ID, "bar") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[1].ID, "bar", "", nil) require.NoError(t, err) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[1].ID}, false, time.Now().Add(1*time.Hour)) require.NoError(t, err) @@ -1619,10 +1619,10 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(1), res.Verified) // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, hosts[1:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, hosts[0:1])) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, hosts[1:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, hosts[0:1])) // create a team team, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"}) @@ -1662,7 +1662,7 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[9].ID, "baz") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[9].ID, "baz", "", nil) require.NoError(t, err) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[9].ID}, true, time.Now().Add(1*time.Hour)) require.NoError(t, err) @@ -1675,10 +1675,10 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verified) // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &team.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &team.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &team.ID, []*fleet.Host{})) upsertHostCPs(hosts[9:10], append(teamCPs, fvTeam), fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerified, ctx, ds, t) res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, &team.ID) @@ -1701,10 +1701,10 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verified) // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &team.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &team.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &team.ID, []*fleet.Host{})) // set decryptable back to true for hosts[9] err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[9].ID}, true, time.Now().Add(1*time.Hour)) @@ -1718,21 +1718,22 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(1), res.Verified) // hosts[9] goes back to verified // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &team.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsPending, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &team.ID, hosts[9:10])) } func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { ctx := context.Background() - checkListHosts := func(status fleet.MacOSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + checkFilterHostsByMacOSSettings := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { expectedIDs := []uint{} for _, h := range expected { expectedIDs = append(expectedIDs, h.ID) } + // check that list hosts by macos settings status matches summary gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String("admin")}}, fleet.HostListOptions{MacOSSettingsFilter: status, TeamFilter: teamID}) gotIDs := []uint{} for _, h := range gotHosts { @@ -1742,6 +1743,26 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { return assert.NoError(t, err) && assert.Len(t, gotHosts, len(expected)) && assert.ElementsMatch(t, expectedIDs, gotIDs) } + // check that list hosts by os settings status matches summary + checkFilterHostsByOSSettings := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + expectedIDs := []uint{} + for _, h := range expected { + expectedIDs = append(expectedIDs, h.ID) + } + + gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String("admin")}}, fleet.HostListOptions{OSSettingsFilter: status, TeamFilter: teamID}) + gotIDs := []uint{} + for _, h := range gotHosts { + gotIDs = append(gotIDs, h.ID) + } + + return assert.NoError(t, err) && assert.Len(t, gotHosts, len(expected)) && assert.ElementsMatch(t, expectedIDs, gotIDs) + } + + checkListHosts := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + return checkFilterHostsByMacOSSettings(status, teamID, expected) && checkFilterHostsByOSSettings(status, teamID, expected) + } + var hosts []*fleet.Host for i := 0; i < 10; i++ { h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", @@ -1766,14 +1787,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // all hosts pending install of all profiles upsertHostCPs(hosts, noTeamCPs, fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryPending, ctx, ds, t) @@ -1784,14 +1805,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[0] and hosts[1] failed one profile upsertHostCPs(hosts[0:2], noTeamCPs[0:1], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryFailed, ctx, ds, t) @@ -1810,14 +1831,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // only count one failure per host (hosts[0] failed two profiles but only counts once) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[0:3] installed a third profile upsertHostCPs(hosts[0:3], noTeamCPs[2:3], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1828,14 +1849,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // no change, host must apply all profiles count as latest require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[6] deletes all its profiles tx, err := ds.writer(ctx).BeginTxx(ctx, nil) @@ -1850,14 +1871,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // no change, host must apply all profiles count as latest require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[9] installed all profiles but one is with status nil (pending) upsertHostCPs(hosts[9:10], noTeamCPs[:9], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1870,14 +1891,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // no change, host must apply all profiles count as latest require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[9] installed all profiles upsertHostCPs(hosts[9:10], noTeamCPs, fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1889,14 +1910,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(1), res.Verifying) // add one host that has installed all profiles require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // create a team tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "rocket"}) @@ -1908,10 +1929,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) // no profiles yet require.Equal(t, uint(0), res.Verifying) // no profiles yet require.Equal(t, uint(0), res.Verified) // no profiles yet - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // transfer hosts[9] to new team err = ds.AddHostsToTeam(ctx, &tm.ID, []uint{hosts[9].ID}) @@ -1926,14 +1947,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(len(hosts)-4), res.Pending) // hosts[9] is still not pending, transferred to team require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // hosts[9] was transferred so this is now zero - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, &tm.ID) // get summary for new team require.NoError(t, err) @@ -1942,10 +1963,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // create somes config profiles for the new team var teamCPs []*fleet.MDMAppleConfigProfile @@ -1964,10 +1985,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // hosts[9] successfully removed old profiles upsertHostCPs(hosts[9:10], noTeamCPs, fleet.MDMAppleOperationTypeRemove, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1978,10 +1999,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(1), res.Verifying) // hosts[9] is verifying all new profiles require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // verify one profile on hosts[9] upsertHostCPs(hosts[9:10], teamCPs[0:1], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerified, ctx, ds, t) @@ -1992,10 +2013,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(1), res.Verifying) // hosts[9] is still verifying other profiles require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // verify the other profiles on hosts[9] upsertHostCPs(hosts[9:10], teamCPs[1:], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerified, ctx, ds, t) @@ -2006,10 +2027,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(1), res.Verified) // hosts[9] is all verified - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, hosts[9:10])) // confirm no changes in summary for profiles with no team res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, ptr.Uint(0)) // team id zero represents no team @@ -2020,14 +2041,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // two failed hosts require.Equal(t, uint(0), res.Verifying) // hosts[9] transferred to new team so is not counted under no team require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) } func testMDMAppleIdPAccount(t *testing.T, ds *Datastore) { @@ -2166,7 +2187,7 @@ func testDeleteMDMAppleProfilesForHost(t *testing.T, ds *Datastore) { } func createDiskEncryptionRecord(ctx context.Context, ds *Datastore, t *testing.T, hostId uint, key string, decryptable bool, threshold time.Time) { - err := ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostId, key) + err := ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostId, key, "", nil) require.NoError(t, err) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hostId}, decryptable, threshold) require.NoError(t, err) diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 185819a15be7..a04d7d1c951f 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -888,7 +888,11 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt } leftJoinFailingPolicies := !useHostPaginationOptim - sql, params = ds.applyHostFilters(opt, sql, filter, params, leftJoinFailingPolicies) + + sql, params, err := ds.applyHostFilters(ctx, opt, sql, filter, params, leftJoinFailingPolicies) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list hosts: apply host filters") + } hosts := []*fleet.Host{} if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, sql, params...); err != nil { @@ -907,7 +911,7 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt } // TODO(Sarah): Do we need to reconcile mutually exclusive filters? -func (ds *Datastore) applyHostFilters(opt fleet.HostListOptions, sql string, filter fleet.TeamFilter, params []interface{}, leftJoinFailingPolicies bool) (string, []interface{}) { +func (ds *Datastore) applyHostFilters(ctx context.Context, opt fleet.HostListOptions, sql string, filter fleet.TeamFilter, params []interface{}, leftJoinFailingPolicies bool) (string, []interface{}, error) { opt.OrderKey = defaultHostColumnTableAlias(opt.OrderKey) deviceMappingJoin := `LEFT JOIN ( @@ -1005,12 +1009,20 @@ func (ds *Datastore) applyHostFilters(opt fleet.HostListOptions, sql string, fil sql, params = filterHostsByMDM(sql, opt, params) sql, params = filterHostsByMacOSSettingsStatus(sql, opt, params) sql, params = filterHostsByMacOSDiskEncryptionStatus(sql, opt, params) + if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil { + return "", nil, err + } else if opt.OSSettingsFilter.IsValid() { + sql, params = ds.filterHostsByOSSettingsStatus(sql, opt, params, enableDiskEncryption) + } else if opt.OSSettingsDiskEncryptionFilter.IsValid() { + sql, params = ds.filterHostsByOSSettingsDiskEncryptionStatus(sql, opt, params, enableDiskEncryption) + } + sql, params = filterHostsByMDMBootstrapPackageStatus(sql, opt, params) sql, params = filterHostsByOS(sql, opt, params) sql, params, _ = hostSearchLike(sql, params, opt.MatchQuery, hostSearchColumns...) sql, params = appendListOptionsWithCursorToSQL(sql, params, &opt.ListOptions) - return sql, params + return sql, params, nil } func filterHostsByTeam(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) { @@ -1116,13 +1128,13 @@ func filterHostsByMacOSSettingsStatus(sql string, opt fleet.HostListOptions, par var subquery string var subqueryParams []interface{} switch opt.MacOSSettingsFilter { - case fleet.MacOSSettingsFailed: + case fleet.OSSettingsFailed: subquery, subqueryParams = subqueryHostsMacOSSettingsStatusFailing() - case fleet.MacOSSettingsPending: + case fleet.OSSettingsPending: subquery, subqueryParams = subqueryHostsMacOSSettingsStatusPending() - case fleet.MacOSSettingsVerifying: + case fleet.OSSettingsVerifying: subquery, subqueryParams = subqueryHostsMacOSSetttingsStatusVerifying() - case fleet.MacOSSettingsVerified: + case fleet.OSSettingsVerified: subquery, subqueryParams = subqueryHostsMacOSSetttingsStatusVerified() } if subquery != "" { @@ -1141,22 +1153,131 @@ func filterHostsByMacOSDiskEncryptionStatus(sql string, opt fleet.HostListOption var subqueryParams []interface{} switch opt.MacOSSettingsDiskEncryptionFilter { case fleet.DiskEncryptionVerified: - subquery, subqueryParams = subqueryDiskEncryptionVerified() + subquery, subqueryParams = subqueryFileVaultVerified() case fleet.DiskEncryptionVerifying: - subquery, subqueryParams = subqueryDiskEncryptionVerifying() + subquery, subqueryParams = subqueryFileVaultVerifying() case fleet.DiskEncryptionActionRequired: - subquery, subqueryParams = subqueryDiskEncryptionActionRequired() + subquery, subqueryParams = subqueryFileVaultActionRequired() case fleet.DiskEncryptionEnforcing: - subquery, subqueryParams = subqueryDiskEncryptionEnforcing() + subquery, subqueryParams = subqueryFileVaultEnforcing() case fleet.DiskEncryptionFailed: - subquery, subqueryParams = subqueryDiskEncryptionFailed() + subquery, subqueryParams = subqueryFileVaultFailed() case fleet.DiskEncryptionRemovingEnforcement: - subquery, subqueryParams = subqueryDiskEncryptionRemovingEnforcement() + subquery, subqueryParams = subqueryFileVaultRemovingEnforcement() } return sql + fmt.Sprintf(` AND EXISTS (%s)`, subquery), append(params, subqueryParams...) } +func (ds *Datastore) filterHostsByOSSettingsStatus(sql string, opt fleet.HostListOptions, params []interface{}, isDiskEncryptionEnabled bool) (string, []interface{}) { + if !opt.OSSettingsFilter.IsValid() { + return sql, params + } + + sqlFmt := ` AND h.platform IN('windows', 'darwin')` + if opt.TeamFilter == nil { + // macOS settings filter is not compatible with the "all teams" option so append the "no + // team" filter here (note that filterHostsByTeam applies the "no team" filter if TeamFilter == 0) + sqlFmt += ` AND h.team_id IS NULL` + } + sqlFmt += ` AND ((h.platform = 'windows' AND (%s)) OR (h.platform = 'darwin' AND (%s)))` + + var subqueryMacOS string + var subqueryParams []interface{} + whereWindows := "FALSE" + whereMacOS := "FALSE" + + switch opt.OSSettingsFilter { + case fleet.OSSettingsFailed: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSettingsStatusFailing() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionFailed) + } + case fleet.OSSettingsPending: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSettingsStatusPending() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing) + } + case fleet.OSSettingsVerifying: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSetttingsStatusVerifying() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying) + } + case fleet.OSSettingsVerified: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSetttingsStatusVerified() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerified) + } + } + + if subqueryMacOS != "" { + whereMacOS = "EXISTS (" + subqueryMacOS + ")" + } + + return sql + fmt.Sprintf(sqlFmt, whereWindows, whereMacOS), append(params, subqueryParams...) +} + +func (ds *Datastore) filterHostsByOSSettingsDiskEncryptionStatus(sql string, opt fleet.HostListOptions, params []interface{}, enableDiskEncryption bool) (string, []interface{}) { + if !opt.OSSettingsDiskEncryptionFilter.IsValid() { + return sql, params + } + + sqlFmt := " AND h.platform IN('windows', 'darwin')" + // TODO: Should we add no team filter here? It isn't included for the FileVault filter but is + // for the general macOS settings filter. + if opt.TeamFilter == nil { + // macOS settings filter is not compatible with the "all teams" option so append the "no + // team" filter here (note that filterHostsByTeam applies the "no team" filter if TeamFilter == 0) + sqlFmt += ` AND h.team_id IS NULL` + } + sqlFmt += ` AND ((h.platform = 'windows' AND %s) OR (h.platform = 'darwin' AND %s))` + + var subqueryMacOS string + var subqueryParams []interface{} + whereWindows := "FALSE" + whereMacOS := "FALSE" + + switch opt.OSSettingsDiskEncryptionFilter { + case fleet.DiskEncryptionVerified: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerified) + } + subqueryMacOS, subqueryParams = subqueryFileVaultVerified() + + case fleet.DiskEncryptionVerifying: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying) + } + subqueryMacOS, subqueryParams = subqueryFileVaultVerifying() + + case fleet.DiskEncryptionActionRequired: + // Windows hosts cannot be action required status in the current implementation. + subqueryMacOS, subqueryParams = subqueryFileVaultActionRequired() + + case fleet.DiskEncryptionEnforcing: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing) + } + subqueryMacOS, subqueryParams = subqueryFileVaultEnforcing() + + case fleet.DiskEncryptionFailed: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionFailed) + } + subqueryMacOS, subqueryParams = subqueryFileVaultFailed() + + case fleet.DiskEncryptionRemovingEnforcement: + // Windows hosts cannot be removing enforcement status in the current implementation. + subqueryMacOS, subqueryParams = subqueryFileVaultRemovingEnforcement() + } + + if subqueryMacOS != "" { + whereMacOS = "EXISTS (" + subqueryMacOS + ")" + } + + return sql + fmt.Sprintf(sqlFmt, whereWindows, whereMacOS), append(params, subqueryParams...) +} + func filterHostsByMDMBootstrapPackageStatus(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) { if opt.MDMBootstrapPackageFilter == nil || !opt.MDMBootstrapPackageFilter.IsValid() { return sql, params @@ -1211,7 +1332,11 @@ func (ds *Datastore) CountHosts(ctx context.Context, filter fleet.TeamFilter, op leftJoinFailingPolicies := false var params []interface{} - sql, params = ds.applyHostFilters(opt, sql, filter, params, leftJoinFailingPolicies) + + sql, params, err := ds.applyHostFilters(ctx, opt, sql, filter, params, leftJoinFailingPolicies) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "count hosts: apply host filters") + } var count int if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, sql, params...); err != nil { @@ -1743,13 +1868,14 @@ func (ds *Datastore) LoadHostByNodeKey(ctx context.Context, nodeKey string) (*fl type hostWithMDMInfo struct { fleet.Host - HostID *uint `db:"host_id"` - Enrolled *bool `db:"enrolled"` - ServerURL *string `db:"server_url"` - InstalledFromDep *bool `db:"installed_from_dep"` - IsServer *bool `db:"is_server"` - MDMID *uint `db:"mdm_id"` - Name *string `db:"name"` + HostID *uint `db:"host_id"` + Enrolled *bool `db:"enrolled"` + ServerURL *string `db:"server_url"` + InstalledFromDep *bool `db:"installed_from_dep"` + IsServer *bool `db:"is_server"` + MDMID *uint `db:"mdm_id"` + Name *string `db:"name"` + EncryptionKeyAvailable *bool `db:"encryption_key_available"` } // LoadHostByOrbitNodeKey loads the whole host identified by the node key. @@ -1805,7 +1931,9 @@ func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) COALESCE(hm.is_server, false) AS is_server, COALESCE(mdms.name, ?) AS name, COALESCE(hdek.reset_requested, false) AS disk_encryption_reset_requested, + COALESCE(hdek.decryptable, false) as encryption_key_available, IF(hdep.host_id AND ISNULL(hdep.deleted_at), true, false) AS dep_assigned_to_fleet, + hd.encrypted as disk_encryption_enabled, t.name as team_name FROM hosts h @@ -1825,6 +1953,10 @@ func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) host_disk_encryption_keys hdek ON hdek.host_id = h.id + LEFT OUTER JOIN + host_disks hd + ON + hd.host_id = h.id LEFT OUTER JOIN teams t ON @@ -1847,6 +1979,10 @@ func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) MDMID: hostWithMDM.MDMID, Name: *hostWithMDM.Name, } + + host.MDM = fleet.MDMHostData{ + EncryptionKeyAvailable: *hostWithMDM.EncryptionKeyAvailable, + } } return &host, nil case errors.Is(err, sql.ErrNoRows): @@ -3014,19 +3150,30 @@ func (ds *Datastore) SetOrUpdateHostDisksEncryption(ctx context.Context, hostID ) } -func (ds *Datastore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string) error { +func (ds *Datastore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key, clientError string, decryptable *bool) error { _, err := ds.writer(ctx).ExecContext(ctx, ` - INSERT INTO host_disk_encryption_keys (host_id, base64_encrypted) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE - /* if the key has changed, NULLify this value so it can be calculated again */ - decryptable = IF(base64_encrypted = VALUES(base64_encrypted), decryptable, NULL), - base64_encrypted = VALUES(base64_encrypted) - `, hostID, encryptedBase64Key) +INSERT INTO host_disk_encryption_keys + (host_id, base64_encrypted, client_error, decryptable) +VALUES + (?, ?, ?, ?) +ON DUPLICATE KEY UPDATE + /* if the key has changed, set decrypted to its initial value so it can be calculated again if necessary (if null) */ + decryptable = IF(base64_encrypted = VALUES(base64_encrypted), decryptable, VALUES(decryptable)), + base64_encrypted = VALUES(base64_encrypted), + client_error = VALUES(client_error) +`, hostID, encryptedBase64Key, clientError, decryptable) return err } func (ds *Datastore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { + // NOTE(mna): currently we only verify encryption keys for macOS, + // Windows/bitlocker uses a different approach where orbit sends the + // encryption key and we encrypt it server-side with the WSTEP certificate, + // so it is always decryptable once received. + // + // To avoid sending Windows-related keys to verify as part of this call, we + // only return rows that have a non-empty encryption key (for Windows, the + // key is blanked if an error occurred trying to retrieve it on the host). var keys []fleet.HostDiskEncryptionKey err := sqlx.SelectContext(ctx, ds.reader(ctx), &keys, ` SELECT @@ -3036,7 +3183,8 @@ func (ds *Datastore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fle FROM host_disk_encryption_keys WHERE - decryptable IS NULL + decryptable IS NULL AND + base64_encrypted != '' `) return keys, err } diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 9a5adcbe8e4e..d7f4f1c090d7 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" @@ -111,7 +112,7 @@ func TestHosts(t *testing.T) { {"HostsListBySoftwareChangedAt", testHostsListBySoftwareChangedAt}, {"HostsListByOperatingSystemID", testHostsListByOperatingSystemID}, {"HostsListByOSNameAndVersion", testHostsListByOSNameAndVersion}, - {"HostsListByDiskEncryptionStatus", testHostsListDiskEncryptionStatus}, + {"HostsListByDiskEncryptionStatus", testHostsListMacOSSettingsDiskEncryptionStatus}, {"HostsListFailingPolicies", printReadsInTest(testHostsListFailingPolicies)}, {"HostsExpiration", testHostsExpiration}, {"HostsAllPackStats", testHostsAllPackStats}, @@ -722,8 +723,13 @@ func testHostListOptionsTeamFilter(t *testing.T, ds *Datastore) { var hosts []*fleet.Host for i := 0; i < 10; i++ { + var opts []test.NewHostOption + switch i { + case 5, 6: + opts = append(opts, test.WithPlatform("windows")) + } h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", - fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now()) + fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now(), opts...) hosts = append(hosts, h) } userFilter := fleet.TeamFilter{User: test.UserAdmin} @@ -763,12 +769,12 @@ func testHostListOptionsTeamFilter(t *testing.T, ds *Datastore) { Checksum: []byte("csum"), }, })) - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[0] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(context.Background(), []*fleet.MDMAppleBulkUpsertHostProfilePayload{ { @@ -781,12 +787,39 @@ func testHostListOptionsTeamFilter(t *testing.T, ds *Datastore) { Checksum: []byte("csum"), }, })) - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[0] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so both teamIDFilterNil acts the same as teamIDFilterZero - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[9] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[9] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + + // test team filter in combination with os settings filter + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, OSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, OSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team + // os settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, OSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, OSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{OSSettingsFilter: fleet.OSSettingsVerifying}, 1) + + // test team filter in combination with os settings disk encryptionfilter + require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(context.Background(), []*fleet.MDMAppleBulkUpsertHostProfilePayload{ + { + ProfileID: 1, + ProfileIdentifier: mobileconfig.FleetFileVaultPayloadIdentifier, + HostUUID: hosts[8].UUID, // hosts[8] is assgined to no team + CommandUUID: "command-uuid-3", + OperationType: fleet.MDMAppleOperationTypeInstall, + Status: &fleet.MDMAppleDeliveryPending, + Checksum: []byte("disk-encryption-csum"), + }, + })) + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 0) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 0) // wrong team + // os settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) // hosts[8] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) // hosts[8] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) // hosts[8] } func testHostsListFilterAdditional(t *testing.T, ds *Datastore) { @@ -2920,7 +2953,7 @@ func testHostsListByOSNameAndVersion(t *testing.T, ds *Datastore) { } } -func testHostsListDiskEncryptionStatus(t *testing.T, ds *Datastore) { +func testHostsListMacOSSettingsDiskEncryptionStatus(t *testing.T, ds *Datastore) { ctx := context.Background() // seed hosts @@ -5745,7 +5778,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { err = ds.SetOrUpdateHostOrbitInfo(context.Background(), host.ID, "1.1.0") require.NoError(t, err) // set an encryption key - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "TESTKEY", "", nil) require.NoError(t, err) // set an mdm profile prof, err := ds.NewMDMAppleConfigProfile(context.Background(), *configProfileForTest(t, "N1", "I1", "U1")) @@ -6591,23 +6624,26 @@ func testHostsLoadHostByOrbitNodeKey(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, hFleet.ID, loadFleet.ID) require.False(t, loadFleet.MDMInfo.IsServer) -} -func checkEncryptionKeyStatus(t *testing.T, ds *Datastore, hostID uint, expected *bool) { - ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - var actual *bool - - row := tx.QueryRowxContext( - context.Background(), - "SELECT decryptable FROM host_disk_encryption_keys WHERE host_id = ?", - hostID, - ) + // fill in disk encryption information + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(context.Background(), hFleet.ID, true)) + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hFleet.ID, "test-key", "", nil) + require.NoError(t, err) + err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hFleet.ID}, true, time.Now()) + require.NoError(t, err) + loadFleet, err = ds.LoadHostByOrbitNodeKey(ctx, *hFleet.OrbitNodeKey) + require.NoError(t, err) + require.True(t, loadFleet.MDM.EncryptionKeyAvailable) + require.NoError(t, err) + require.NotNil(t, loadFleet.DiskEncryptionEnabled) + require.True(t, *loadFleet.DiskEncryptionEnabled) +} - err := row.Scan(&actual) - require.NoError(t, err) - require.Equal(t, expected, actual) - return nil - }) +func checkEncryptionKeyStatus(t *testing.T, ds *Datastore, hostID uint, expectedKey string, expectedDecryptable *bool) { + got, err := ds.GetHostDiskEncryptionKey(context.Background(), hostID) + require.NoError(t, err) + require.Equal(t, expectedKey, got.Base64Encrypted) + require.Equal(t, expectedDecryptable, got.Decryptable) } func testHostsSetOrUpdateHostDisksEncryptionKey(t *testing.T, ds *Datastore) { @@ -6637,49 +6673,81 @@ func testHostsSetOrUpdateHostDisksEncryptionKey(t *testing.T, ds *Datastore) { PrimaryMac: "30-65-EC-6F-C4-59", }) require.NoError(t, err) - - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA") + host3, err := ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("3"), + UUID: "3", + OsqueryHostID: ptr.String("3"), + Hostname: "foo.local3", + PrimaryIP: "192.168.1.3", + PrimaryMac: "30-65-EC-6F-C4-60", + }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "BBB") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA", "", nil) require.NoError(t, err) - checkEncryptionKey := func(hostID uint, expected string) { - actual, err := ds.GetHostDiskEncryptionKey(context.Background(), hostID) - require.NoError(t, err) - require.Equal(t, expected, actual.Base64Encrypted) - } + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "BBB", "", nil) + require.NoError(t, err) h, err := ds.Host(context.Background(), host.ID) require.NoError(t, err) - checkEncryptionKey(h.ID, "AAA") + checkEncryptionKeyStatus(t, ds, h.ID, "AAA", nil) h, err = ds.Host(context.Background(), host2.ID) require.NoError(t, err) - checkEncryptionKey(h.ID, "BBB") + checkEncryptionKeyStatus(t, ds, h.ID, "BBB", nil) - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "CCC") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "CCC", "", nil) require.NoError(t, err) h, err = ds.Host(context.Background(), host2.ID) require.NoError(t, err) - checkEncryptionKey(h.ID, "CCC") + checkEncryptionKeyStatus(t, ds, h.ID, "CCC", nil) // setting the encryption key to an existing value doesn't change its // encryption status err = ds.SetHostsDiskEncryptionKeyStatus(context.Background(), []uint{host.ID}, true, time.Now().Add(time.Hour)) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host.ID, "AAA", ptr.Bool(true)) // same key doesn't change encryption status - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA", "", nil) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host.ID, "AAA", ptr.Bool(true)) // different key resets encryption status - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "XZY") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "XZY", "", nil) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host.ID, "XZY", nil) + + // set the key with an initial decrypted status of true + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "abc", "", ptr.Bool(true)) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, nil) + checkEncryptionKeyStatus(t, ds, host3.ID, "abc", ptr.Bool(true)) + + // same key, provided decrypted status is ignored (stored one is kept) + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "abc", "", ptr.Bool(false)) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "abc", ptr.Bool(true)) + + // client error, key is removed and decrypted status is nulled + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "", "fail", nil) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "", nil) + + // new key, provided decrypted status is applied + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "def", "", ptr.Bool(true)) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "def", ptr.Bool(true)) + + // different key, provided decrypted status is applied + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "ghi", "", ptr.Bool(false)) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "ghi", ptr.Bool(false)) } func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { @@ -6697,7 +6765,7 @@ func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { PrimaryMac: "30-65-EC-6F-C4-58", }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY", "", nil) require.NoError(t, err) host2, err := ds.NewHost(context.Background(), &fleet.Host{ @@ -6714,7 +6782,7 @@ func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY", "", nil) require.NoError(t, err) threshold := time.Now().Add(time.Hour) @@ -6722,31 +6790,31 @@ func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { // empty set err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{}, false, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, nil) - checkEncryptionKeyStatus(t, ds, host2.ID, nil) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", nil) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", nil) // keys that changed after the provided threshold are not updated err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, true, threshold.Add(-24*time.Hour)) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, nil) - checkEncryptionKeyStatus(t, ds, host2.ID, nil) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", nil) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", nil) // single host err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, true, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) - checkEncryptionKeyStatus(t, ds, host2.ID, nil) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", nil) // multiple hosts err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, true, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) - checkEncryptionKeyStatus(t, ds, host2.ID, ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", ptr.Bool(true)) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, false, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(false)) - checkEncryptionKeyStatus(t, ds, host2.ID, ptr.Bool(false)) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", ptr.Bool(false)) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", ptr.Bool(false)) } func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) { @@ -6778,9 +6846,9 @@ func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY", "", nil) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY", "", nil) require.NoError(t, err) keys, err := ds.GetUnverifiedDiskEncryptionKeys(ctx) @@ -6799,6 +6867,17 @@ func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) { keys, err = ds.GetUnverifiedDiskEncryptionKeys(ctx) require.NoError(t, err) require.Len(t, keys, 1) + require.Equal(t, host2.ID, keys[0].HostID) + + // update key of host 1 to empty with a client error, should not be reported + // by GetUnverifiedDiskEncryptionKeys + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "", "failed", nil) + require.NoError(t, err) + + keys, err = ds.GetUnverifiedDiskEncryptionKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, host2.ID, keys[0].HostID) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, false, threshold) require.NoError(t, err) @@ -6997,7 +7076,7 @@ func testHostsEncryptionKeyRawDecryption(t *testing.T, ds *Datastore) { require.Equal(t, -1, *got.MDM.TestGetRawDecryptable()) // create the encryption key row, but unknown decryptable - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "abc") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "abc", "", nil) require.NoError(t, err) got, err = ds.Host(ctx, host.ID) diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 551ad0463565..2573a53da5c8 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -552,10 +552,13 @@ func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilt query := fmt.Sprintf(queryFmt, hostMDMSelect, failingPoliciesSelect, deviceMappingSelect, hostMDMJoin, failingPoliciesJoin, deviceMappingJoin) - query, params := ds.applyHostLabelFilters(filter, lid, query, opt) + query, params, err := ds.applyHostLabelFilters(ctx, filter, lid, query, opt) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "applying label query filters") + } hosts := []*fleet.Host{} - err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, query, params...) + err = sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, query, params...) if err != nil { return nil, ctxerr.Wrap(ctx, err, "selecting label query executions") } @@ -563,7 +566,7 @@ func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilt } // NOTE: the hosts table must be aliased to `h` in the query passed to this function. -func (ds *Datastore) applyHostLabelFilters(filter fleet.TeamFilter, lid uint, query string, opt fleet.HostListOptions) (string, []interface{}) { +func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.TeamFilter, lid uint, query string, opt fleet.HostListOptions) (string, []interface{}, error) { params := []interface{}{lid} if opt.ListOptions.OrderKey == "display_name" { @@ -582,26 +585,33 @@ func (ds *Datastore) applyHostLabelFilters(filter fleet.TeamFilter, lid uint, qu query, params = filterHostsByMacOSSettingsStatus(query, opt, params) query, params = filterHostsByMacOSDiskEncryptionStatus(query, opt, params) query, params = filterHostsByMDMBootstrapPackageStatus(query, opt, params) + if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil { + return "", nil, err + } else if opt.OSSettingsFilter.IsValid() { + query, params = ds.filterHostsByOSSettingsStatus(query, opt, params, enableDiskEncryption) + } else if opt.OSSettingsDiskEncryptionFilter.IsValid() { + query, params = ds.filterHostsByOSSettingsDiskEncryptionStatus(query, opt, params, enableDiskEncryption) + } query, params = searchLike(query, params, opt.MatchQuery, hostSearchColumns...) query, params = appendListOptionsWithCursorToSQL(query, params, &opt.ListOptions) - return query, params + return query, params, nil } func (ds *Datastore) CountHostsInLabel(ctx context.Context, filter fleet.TeamFilter, lid uint, opt fleet.HostListOptions) (int, error) { query := `SELECT count(*) FROM label_membership lm JOIN hosts h ON (lm.host_id = h.id) LEFT JOIN host_seen_times hst ON (h.id=hst.host_id) + LEFT JOIN host_disks hd ON (h.id=hd.host_id) ` query += hostMDMJoin - if opt.LowDiskSpaceFilter != nil { - query += ` LEFT JOIN host_disks hd ON (h.id=hd.host_id) ` + query, params, err := ds.applyHostLabelFilters(ctx, filter, lid, query, opt) + if err != nil { + return 0, err } - query, params := ds.applyHostLabelFilters(filter, lid, query, opt) - var count int if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, query, params...); err != nil { return 0, ctxerr.Wrap(ctx, err, "count hosts") diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index db4ef804946b..377cf2243222 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" @@ -66,6 +67,7 @@ func TestLabels(t *testing.T) { {"ListHostsInLabelFailingPolicies", testListHostsInLabelFailingPolicies}, {"ListHostsInLabelDiskEncryptionStatus", testListHostsInLabelDiskEncryptionStatus}, {"HostMemberOfAllLabels", testHostMemberOfAllLabels}, + {"ListHostsInLabelOSSettings", testLabelsListHostsInLabelOSSettings}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -497,12 +499,12 @@ func testLabelsListHostsInLabelAndTeamFilter(deferred bool, t *testing.T, db *Da Checksum: []byte("csum"), }, })) - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h1 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h1 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team require.NoError(t, db.BulkUpsertMDMAppleHostProfiles(context.Background(), []*fleet.MDMAppleBulkUpsertHostProfilePayload{ { @@ -515,12 +517,12 @@ func testLabelsListHostsInLabelAndTeamFilter(deferred bool, t *testing.T, db *Da Checksum: []byte("csum"), }, })) - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h1 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h1 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so both teamIDFilterNil acts the same as teamIDFilterZero - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h2 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h2 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h2 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h2 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h2 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h2 } func testLabelsBuiltIn(t *testing.T, db *Datastore) { @@ -1329,3 +1331,97 @@ func testHostMemberOfAllLabels(t *testing.T, ds *Datastore) { }) } } + +func testLabelsListHostsInLabelOSSettings(t *testing.T, db *Datastore) { + h1, err := db.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("1"), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + Platform: "windows", + }) + require.NoError(t, err) + + h2, err := db.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("2"), + NodeKey: ptr.String("2"), + UUID: "2", + Hostname: "bar.local", + Platform: "windows", + }) + require.NoError(t, err) + h3, err := db.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("3"), + NodeKey: ptr.String("3"), + UUID: "3", + Hostname: "baz.local", + Platform: "centos", + }) + require.NoError(t, err) + + l1 := &fleet.LabelSpec{ + ID: 1, + Name: "label foo", + Query: "query1", + } + err = db.ApplyLabelSpecs(context.Background(), []*fleet.LabelSpec{l1}) + require.Nil(t, err) + + filter := fleet.TeamFilter{User: test.UserAdmin} + // add all hosts to label + for _, h := range []*fleet.Host{h1, h2, h3} { + require.NoError(t, db.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{l1.ID: ptr.Bool(true)}, time.Now(), false)) + } + + // turn on disk encryption + ac, err := db.AppConfig(context.Background()) + require.NoError(t, err) + ac.MDM.EnableDiskEncryption = optjson.SetBool(true) + require.NoError(t, db.SaveAppConfig(context.Background(), ac)) + + // add two hosts to MDM to enforce disk encryption, fleet doesn't enforce settings on centos so h3 is not included + for _, h := range []*fleet.Host{h1, h2} { + require.NoError(t, db.SetOrUpdateMDMData(context.Background(), h.ID, false, true, "https://example.com", false, fleet.WellKnownMDMFleet)) + } + // add disk encryption key for h1 + require.NoError(t, db.SetOrUpdateHostDiskEncryptionKey(context.Background(), h1.ID, "test-key", "", ptr.Bool(true))) + // add disk encryption for h1 + require.NoError(t, db.SetOrUpdateHostDisksEncryption(context.Background(), h1.ID, true)) + + checkHosts := func(t *testing.T, gotHosts []*fleet.Host, expectedIDs []uint) { + require.Len(t, gotHosts, len(expectedIDs)) + for _, h := range gotHosts { + require.Contains(t, expectedIDs, h.ID) + } + } + + // baseline no filter + hosts := listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{}, 3) + checkHosts(t, hosts, []uint{h1.ID, h2.ID, h3.ID}) + + t.Run("os_settings", func(t *testing.T) { + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionVerified}, 1) + checkHosts(t, hosts, []uint{h1.ID}) + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) + checkHosts(t, hosts, []uint{h2.ID}) + }) + + t.Run("os_settings_disk_encryption", func(t *testing.T) { + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsFilter: fleet.OSSettingsVerified}, 1) + checkHosts(t, hosts, []uint{h1.ID}) + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsFilter: fleet.OSSettingsPending}, 1) + checkHosts(t, hosts, []uint{h2.ID}) + }) +} diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index 863f9c290fbd..7dd3a696fcc3 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -3,13 +3,16 @@ package mysql import ( "context" "database/sql" + "errors" + "fmt" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-kit/kit/log/level" "github.com/jmoiron/sqlx" ) -// MDMWindowsGetEnrolledDevice receives a Windows MDM device id and returns the device information. +// MDMWindowsGetEnrolledDevice receives a Windows MDM HW Device id and returns the device information. func (ds *Datastore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceHWID string) (*fleet.MDMWindowsEnrolledDevice, error) { stmt := `SELECT mdm_device_id, @@ -36,6 +39,33 @@ func (ds *Datastore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceH return &winMDMDevice, nil } +// MDMWindowsGetEnrolledDeviceWithDeviceID receives a Windows MDM device id and returns the device information. +func (ds *Datastore) MDMWindowsGetEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + stmt := `SELECT + mdm_device_id, + mdm_hardware_id, + device_state, + device_type, + device_name, + enroll_type, + enroll_user_id, + enroll_proto_version, + enroll_client_version, + not_in_oobe, + created_at, + updated_at + FROM mdm_windows_enrollments WHERE mdm_device_id = ?` + + var winMDMDevice fleet.MDMWindowsEnrolledDevice + if err := sqlx.GetContext(ctx, ds.reader(ctx), &winMDMDevice, stmt, mdmDeviceID); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("MDMWindowsGetEnrolledDeviceWithDeviceID").WithMessage(mdmDeviceID)) + } + return nil, ctxerr.Wrap(ctx, err, "get MDMWindowsGetEnrolledDeviceWithDeviceID") + } + return &winMDMDevice, nil +} + // MDMWindowsInsertEnrolledDevice inserts a new MDMWindowsEnrolledDevice in the database func (ds *Datastore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device *fleet.MDMWindowsEnrolledDevice) error { stmt := ` @@ -74,7 +104,8 @@ func (ds *Datastore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device return nil } -// MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the device id. +// MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database +// using the HW Device ID. func (ds *Datastore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceHWID string) error { stmt := "DELETE FROM mdm_windows_enrollments WHERE mdm_hardware_id = ?" @@ -90,3 +121,202 @@ func (ds *Datastore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDevi return ctxerr.Wrap(ctx, notFound("MDMWindowsEnrolledDevice")) } + +// MDMWindowsDeleteEnrolledDeviceWithDeviceID deletes a give MDMWindowsEnrolledDevice entry from the database using the device id. +func (ds *Datastore) MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error { + stmt := "DELETE FROM mdm_windows_enrollments WHERE mdm_device_id = ?" + + res, err := ds.writer(ctx).ExecContext(ctx, stmt, mdmDeviceID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete MDMWindowsDeleteEnrolledDeviceWithDeviceID") + } + + deleted, _ := res.RowsAffected() + if deleted == 1 { + return nil + } + + return ctxerr.Wrap(ctx, notFound("MDMWindowsDeleteEnrolledDeviceWithDeviceID")) +} + +// whereBitLockerStatus returns a string suitable for inclusion within a SQL WHERE clause to filter by +// the given status. The caller is responsible for ensuring the status is valid. In the case of an invalid +// status, the function will return the string "FALSE". The caller should also ensure that the query in +// which this is used joins the following tables with the specified aliases: +// - host_disk_encryption_keys: hdek +// - host_mdm: hmdm +// - host_disks: hd +func (ds *Datastore) whereBitLockerStatus(status fleet.DiskEncryptionStatus) string { + const ( + whereNotServer = `(hmdm.is_server IS NOT NULL AND hmdm.is_server = 0)` + whereKeyAvailable = `(hdek.base64_encrypted IS NOT NULL AND hdek.base64_encrypted != '' AND hdek.decryptable IS NOT NULL AND hdek.decryptable = 1)` + whereEncrypted = `(hd.encrypted IS NOT NULL AND hd.encrypted = 1)` + whereHostDisksUpdated = `(hd.updated_at IS NOT NULL AND hdek.updated_at IS NOT NULL AND hd.updated_at >= hdek.updated_at)` + whereClientError = `(hdek.client_error IS NOT NULL AND hdek.client_error != '')` + withinGracePeriod = `(hdek.updated_at IS NOT NULL AND hdek.updated_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR))` + ) + + // TODO: what if windows sends us a key for an already encrypted volumne? could it get stuck + // in pending or verifying? should we modify SetOrUpdateHostDiskEncryption to ensure that we + // increment the updated_at timestamp on the host_disks table for all encrypted volumes + // host_disks if the hdek timestamp is newer? What about SetOrUpdateHostDiskEncryptionKey? + + switch status { + case fleet.DiskEncryptionVerified: + return whereNotServer + ` +AND NOT ` + whereClientError + ` +AND ` + whereKeyAvailable + ` +AND ` + whereEncrypted + ` +AND ` + whereHostDisksUpdated + + case fleet.DiskEncryptionVerifying: + // Possible verifying scenarios: + // - we have the key and host_disks already encrypted before the key but hasn't been updated yet + // - we have the key and host_disks reported unencrypted during the 1-hour grace period after key was updated + return whereNotServer + ` +AND NOT ` + whereClientError + ` +AND ` + whereKeyAvailable + ` +AND ( + (` + whereEncrypted + ` AND NOT ` + whereHostDisksUpdated + `) + OR (NOT ` + whereEncrypted + ` AND ` + whereHostDisksUpdated + ` AND ` + withinGracePeriod + `) +)` + + case fleet.DiskEncryptionEnforcing: + // Possible enforcing scenarios: + // - we don't have the key + // - we have the key and host_disks reported unencrypted before the key was updated or outside the 1-hour grace period after key was updated + return whereNotServer + ` +AND NOT ` + whereClientError + ` +AND ( + NOT ` + whereKeyAvailable + ` + OR (` + whereKeyAvailable + ` + AND (NOT ` + whereEncrypted + ` + AND (NOT ` + whereHostDisksUpdated + ` OR NOT ` + withinGracePeriod + `) + ) + ) +)` + + case fleet.DiskEncryptionFailed: + return whereNotServer + ` AND ` + whereClientError + + default: + level.Debug(ds.logger).Log("msg", "unknown bitlocker status", "status", status) + return "FALSE" + } +} + +func (ds *Datastore) GetMDMWindowsBitLockerSummary(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + enabled, err := ds.getConfigEnableDiskEncryption(ctx, teamID) + if err != nil { + return nil, err + } + if !enabled { + return &fleet.MDMWindowsBitLockerSummary{}, nil + } + + // Note action_required and removing_enforcement are not applicable to Windows hosts + sqlFmt := ` +SELECT + COUNT(if((%s), 1, NULL)) AS verified, + COUNT(if((%s), 1, NULL)) AS verifying, + 0 AS action_required, + COUNT(if((%s), 1, NULL)) AS enforcing, + COUNT(if((%s), 1, NULL)) AS failed, + 0 AS removing_enforcement +FROM + hosts h + LEFT JOIN host_disk_encryption_keys hdek ON h.id = hdek.host_id + LEFT JOIN host_mdm hmdm ON h.id = hmdm.host_id + LEFT JOIN host_disks hd ON h.id = hd.host_id +WHERE + h.platform = 'windows' AND hmdm.is_server = 0 AND %s` + + var args []interface{} + teamFilter := "h.team_id IS NULL" + if teamID != nil && *teamID > 0 { + teamFilter = "h.team_id = ?" + args = append(args, *teamID) + } + + var res fleet.MDMWindowsBitLockerSummary + stmt := fmt.Sprintf( + sqlFmt, + ds.whereBitLockerStatus(fleet.DiskEncryptionVerified), + ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying), + ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing), + ds.whereBitLockerStatus(fleet.DiskEncryptionFailed), + teamFilter, + ) + if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil { + return nil, err + } + + return &res, nil +} + +func (ds *Datastore) GetMDMWindowsBitLockerStatus(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) { + if host == nil { + return nil, errors.New("host cannot be nil") + } + + if host.Platform != "windows" { + // Generally, the caller should have already checked this, but just in case we log and + // return nil + level.Debug(ds.logger).Log("msg", "cannot get bitlocker status for non-windows host", "host_id", host.ID) + return nil, nil + } + + if host.MDMInfo != nil && host.MDMInfo.IsServer { + // It is currently expected that server hosts do not have a bitlocker status so we can skip + // the query and return nil. We log for potential debugging in case this changes in the future. + level.Debug(ds.logger).Log("msg", "no bitlocker status for server host", "host_id", host.ID) + return nil, nil + } + + enabled, err := ds.getConfigEnableDiskEncryption(ctx, host.TeamID) + if err != nil { + return nil, err + } + if !enabled { + return nil, nil + } + + // Note action_required and removing_enforcement are not applicable to Windows hosts + stmt := fmt.Sprintf(` +SELECT + CASE + WHEN (%s) THEN '%s' + WHEN (%s) THEN '%s' + WHEN (%s) THEN '%s' + WHEN (%s) THEN '%s' + END AS status +FROM + host_mdm hmdm + LEFT JOIN host_disk_encryption_keys hdek ON hmdm.host_id = hdek.host_id + LEFT JOIN host_disks hd ON hmdm.host_id = hd.host_id +WHERE + hmdm.host_id = ?`, + ds.whereBitLockerStatus(fleet.DiskEncryptionVerified), + fleet.DiskEncryptionVerified, + ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying), + fleet.DiskEncryptionVerifying, + ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing), + fleet.DiskEncryptionEnforcing, + ds.whereBitLockerStatus(fleet.DiskEncryptionFailed), + fleet.DiskEncryptionFailed, + ) + + var des fleet.DiskEncryptionStatus + if err := sqlx.GetContext(ctx, ds.reader(ctx), &des, stmt, host.ID); err != nil { + if err == sql.ErrNoRows { + // At this point we know disk encryption is enabled so if we don't have a record for the + // host then we treat it as enforcing and log for potential debugging + level.Debug(ds.logger).Log("msg", "no bitlocker status found for host", "host_id", host.ID) + des = fleet.DiskEncryptionEnforcing + return &des, nil + } + return nil, err + } + + return &des, nil +} diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 00e2a15265cb..e1fe97b2c329 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -3,9 +3,15 @@ package mysql import ( "context" // nolint:gosec // used only to hash for efficient comparisons "testing" + "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -66,4 +72,387 @@ func testMDMWindowsEnrolledDevice(t *testing.T, ds *Datastore) { err = ds.MDMWindowsDeleteEnrolledDevice(ctx, enrolledDevice.MDMHardwareID) require.ErrorAs(t, err, &nfe) + + // Test using device ID instead of hardware ID + err = ds.MDMWindowsInsertEnrolledDevice(ctx, enrolledDevice) + require.NoError(t, err) + + err = ds.MDMWindowsInsertEnrolledDevice(ctx, enrolledDevice) + require.ErrorAs(t, err, &ae) + + gotEnrolledDevice, err = ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, enrolledDevice.MDMDeviceID) + require.NoError(t, err) + require.NotZero(t, gotEnrolledDevice.CreatedAt) + require.Equal(t, enrolledDevice.MDMDeviceID, gotEnrolledDevice.MDMDeviceID) + require.Equal(t, enrolledDevice.MDMHardwareID, gotEnrolledDevice.MDMHardwareID) + + err = ds.MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx, enrolledDevice.MDMDeviceID) + require.NoError(t, err) + + _, err = ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, enrolledDevice.MDMDeviceID) + require.ErrorAs(t, err, &nfe) + + err = ds.MDMWindowsDeleteEnrolledDevice(ctx, enrolledDevice.MDMHardwareID) + require.ErrorAs(t, err, &nfe) +} + +func TestMDMWindowsDiskEncryption(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + checkBitLockerSummary := func(t *testing.T, teamID *uint, expected fleet.MDMWindowsBitLockerSummary) { + bls, err := ds.GetMDMWindowsBitLockerSummary(ctx, teamID) + require.NoError(t, err) + require.NotNil(t, bls) + require.Equal(t, expected, *bls) + } + + checkListHostsFilterOSSettings := func(t *testing.T, teamID *uint, status fleet.OSSettingsStatus, expectedIDs []uint) { + gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status}) + require.NoError(t, err) + require.Len(t, gotHosts, len(expectedIDs)) + for _, h := range gotHosts { + require.Contains(t, expectedIDs, h.ID) + } + } + + checkListHostsFilterDiskEncryption := func(t *testing.T, teamID *uint, status fleet.DiskEncryptionStatus, expectedIDs []uint) { + gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsDiskEncryptionFilter: status}) + require.NoError(t, err) + require.Len(t, gotHosts, len(expectedIDs), "status: %s", status) + for _, h := range gotHosts { + require.Contains(t, expectedIDs, h.ID) + } + } + + checkHostBitLockerStatus := func(t *testing.T, expected fleet.DiskEncryptionStatus, hostIDs []uint) { + for _, id := range hostIDs { + h, err := ds.Host(ctx, id) + require.NoError(t, err) + require.NotNil(t, h) + bls, err := ds.GetMDMWindowsBitLockerStatus(ctx, h) + require.NoError(t, err) + require.NotNil(t, bls) + require.Equal(t, expected, *bls) + } + } + + type hostIDsByStatus map[fleet.DiskEncryptionStatus][]uint + + checkExpected := func(t *testing.T, teamID *uint, expected hostIDsByStatus) { + for _, status := range []fleet.DiskEncryptionStatus{ + fleet.DiskEncryptionVerified, + fleet.DiskEncryptionVerifying, + fleet.DiskEncryptionFailed, + fleet.DiskEncryptionEnforcing, + fleet.DiskEncryptionRemovingEnforcement, + fleet.DiskEncryptionActionRequired, + } { + hostIDs, ok := expected[status] + if !ok { + hostIDs = []uint{} + } + checkListHostsFilterDiskEncryption(t, teamID, status, hostIDs) + checkHostBitLockerStatus(t, status, hostIDs) + } + + checkBitLockerSummary(t, teamID, fleet.MDMWindowsBitLockerSummary{ + Verified: uint(len(expected[fleet.DiskEncryptionVerified])), + Verifying: uint(len(expected[fleet.DiskEncryptionVerifying])), + Failed: uint(len(expected[fleet.DiskEncryptionFailed])), + Enforcing: uint(len(expected[fleet.DiskEncryptionEnforcing])), + RemovingEnforcement: uint(len(expected[fleet.DiskEncryptionRemovingEnforcement])), + ActionRequired: uint(len(expected[fleet.DiskEncryptionActionRequired])), + }) + + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerified, expected[fleet.DiskEncryptionVerified]) + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerifying, expected[fleet.DiskEncryptionVerifying]) + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsFailed, expected[fleet.DiskEncryptionFailed]) + var expectedPending []uint + expectedPending = append(expectedPending, expected[fleet.DiskEncryptionEnforcing]...) + expectedPending = append(expectedPending, expected[fleet.DiskEncryptionRemovingEnforcement]...) + expectedPending = append(expectedPending, expected[fleet.DiskEncryptionActionRequired]...) + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsPending, expectedPending) + } + + updateHostDisks := func(t *testing.T, hostID uint, encrypted bool, updated_at time.Time) { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_disks SET encrypted = ?, updated_at = ? where host_id = ?` + _, err := q.ExecContext(ctx, stmt, encrypted, updated_at, hostID) + return err + }) + } + + setKeyUpdatedAt := func(t *testing.T, hostID uint, keyUpdatedAt time.Time) { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_disk_encryption_keys SET updated_at = ? where host_id = ?` + _, err := q.ExecContext(ctx, stmt, keyUpdatedAt, hostID) + return err + }) + } + + // Create some hosts + var hosts []*fleet.Host + for i := 0; i < 10; i++ { + p := "windows" + if i >= 5 { + p = "darwin" + } + u := uuid.New().String() + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: &u, + UUID: u, + Hostname: u, + Platform: p, + }) + require.NoError(t, err) + require.NotNil(t, h) + hosts = append(hosts, h) + + require.NoError(t, ds.SetOrUpdateMDMData(ctx, h.ID, false, true, "https://example.com", false, fleet.WellKnownMDMFleet)) + } + + t.Run("Disk encryption disabled", func(t *testing.T) { + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + require.False(t, ac.MDM.EnableDiskEncryption.Value) + + checkExpected(t, nil, hostIDsByStatus{}) // no hosts are counted because disk encryption is not enabled + }) + + t.Run("Disk encryption enabled", func(t *testing.T) { + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + ac.MDM.EnableDiskEncryption = optjson.SetBool(true) + require.NoError(t, ds.SaveAppConfig(ctx, ac)) + ac, err = ds.AppConfig(ctx) + require.NoError(t, err) + require.True(t, ac.MDM.EnableDiskEncryption.Value) + + t.Run("Bitlocker enforcing status", func(t *testing.T) { + // all windows hosts are counted as enforcing because they have not reported any disk encryption status yet + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionEnforcing: []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + require.NoError(t, ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "test-key", "", ptr.Bool(true))) + checkExpected(t, nil, hostIDsByStatus{ + // status is still pending because hosts_disks hasn't been updated yet + fleet.DiskEncryptionEnforcing: []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(ctx, hosts[0].ID, true)) + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + cases := []struct { + name string + hostDisksEncrypted bool + reportedAfterKey bool + expectedWithinGracePeriod fleet.DiskEncryptionStatus + expectedOutsideGracePeriod fleet.DiskEncryptionStatus + }{ + { + name: "encrypted reported after key", + hostDisksEncrypted: true, + reportedAfterKey: true, + expectedWithinGracePeriod: fleet.DiskEncryptionVerified, + expectedOutsideGracePeriod: fleet.DiskEncryptionVerified, + }, + { + name: "encrypted reported before key", + hostDisksEncrypted: true, + reportedAfterKey: false, + expectedWithinGracePeriod: fleet.DiskEncryptionVerifying, + expectedOutsideGracePeriod: fleet.DiskEncryptionVerifying, + }, + { + name: "not encrypted reported before key", + hostDisksEncrypted: false, + reportedAfterKey: false, + expectedWithinGracePeriod: fleet.DiskEncryptionEnforcing, + expectedOutsideGracePeriod: fleet.DiskEncryptionEnforcing, + }, + { + name: "not encrypted reported after key", + hostDisksEncrypted: false, + reportedAfterKey: true, + expectedWithinGracePeriod: fleet.DiskEncryptionVerifying, + expectedOutsideGracePeriod: fleet.DiskEncryptionEnforcing, + }, + } + + testHostID := hosts[0].ID + otherWindowsHostIDs := []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID} + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var keyUpdatedAt, hostDisksUpdatedAt time.Time + + t.Run("within grace period", func(t *testing.T) { + expected := make(hostIDsByStatus) + if c.expectedWithinGracePeriod == fleet.DiskEncryptionEnforcing { + expected[fleet.DiskEncryptionEnforcing] = append([]uint{testHostID}, otherWindowsHostIDs...) + } else { + expected[c.expectedWithinGracePeriod] = []uint{testHostID} + expected[fleet.DiskEncryptionEnforcing] = otherWindowsHostIDs + } + + keyUpdatedAt = time.Now().Add(-10 * time.Minute) + setKeyUpdatedAt(t, testHostID, keyUpdatedAt) + + if c.reportedAfterKey { + hostDisksUpdatedAt = keyUpdatedAt.Add(5 * time.Minute) + } else { + hostDisksUpdatedAt = keyUpdatedAt.Add(-5 * time.Minute) + } + updateHostDisks(t, testHostID, c.hostDisksEncrypted, hostDisksUpdatedAt) + + checkExpected(t, nil, expected) + }) + + t.Run("outside grace period", func(t *testing.T) { + expected := make(hostIDsByStatus) + if c.expectedOutsideGracePeriod == fleet.DiskEncryptionEnforcing { + expected[fleet.DiskEncryptionEnforcing] = append([]uint{testHostID}, otherWindowsHostIDs...) + } else { + expected[c.expectedOutsideGracePeriod] = []uint{testHostID} + expected[fleet.DiskEncryptionEnforcing] = otherWindowsHostIDs + } + + keyUpdatedAt = time.Now().Add(-2 * time.Hour) + setKeyUpdatedAt(t, testHostID, keyUpdatedAt) + + if c.reportedAfterKey { + hostDisksUpdatedAt = keyUpdatedAt.Add(5 * time.Minute) + } else { + hostDisksUpdatedAt = keyUpdatedAt.Add(-5 * time.Minute) + } + updateHostDisks(t, testHostID, c.hostDisksEncrypted, hostDisksUpdatedAt) + + checkExpected(t, nil, expected) + }) + }) + } + }) + + // ensure hosts[0] is set to verified for the rest of the tests + require.NoError(t, ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "test-key", "", ptr.Bool(true))) + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(ctx, hosts[0].ID, true)) + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + t.Run("BitLocker failed status", func(t *testing.T) { + // TODO: Update test to use methods to set windows disk encryption when they are implemented + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `INSERT INTO host_disk_encryption_keys (host_id, decryptable, client_error) VALUES (?, ?, ?)`, + hosts[1].ID, + false, + "test-error") + return err + }) + + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + }) + + t.Run("BitLocker team filtering", func(t *testing.T) { + // Test team filtering + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team"}) + require.NoError(t, err) + + tm, err := ds.Team(ctx, team.ID) + require.NoError(t, err) + require.NotNil(t, tm) + require.False(t, tm.Config.MDM.EnableDiskEncryption) // disk encryption is not enabled for team + + // Transfer hosts[2] to the team + require.NoError(t, ds.AddHostsToTeam(ctx, &team.ID, []uint{hosts[2].ID})) + + // Check the summary for the team + checkExpected(t, &team.ID, hostIDsByStatus{}) // disk encryption is not enabled for team so hosts[2] is not counted + + // Check the summary for no team + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[3].ID, hosts[4].ID}, // hosts[2] is no longer included in the no team summary + }) + + // Enable disk encryption for the team + tm.Config.MDM.EnableDiskEncryption = true + tm, err = ds.SaveTeam(ctx, tm) + require.NoError(t, err) + require.NotNil(t, tm) + require.True(t, tm.Config.MDM.EnableDiskEncryption) + + // Check the summary for the team + checkExpected(t, &team.ID, hostIDsByStatus{ + fleet.DiskEncryptionEnforcing: []uint{hosts[2].ID}, // disk encryption is enabled for team so hosts[2] is counted + }) + + // Check the summary for no team (should be unchanged) + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[3].ID, hosts[4].ID}, + }) + }) + + t.Run("BitLocker Windows server excluded", func(t *testing.T) { + require.NoError(t, ds.SetOrUpdateMDMData(ctx, + hosts[3].ID, + true, // set is_server to true for hosts[3] + true, "https://example.com", false, fleet.WellKnownMDMFleet)) + + // Check Windows servers not counted + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[4].ID}, // hosts[3] is not counted + }) + }) + + t.Run("OS settings filters include Windows and macOS hosts", func(t *testing.T) { + // Make macOS host fail disk encryption + require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ + { + HostUUID: hosts[5].UUID, + ProfileIdentifier: mobileconfig.FleetFileVaultPayloadIdentifier, + ProfileName: "Disk encryption", + ProfileID: 1, + CommandUUID: uuid.New().String(), + OperationType: fleet.MDMAppleOperationTypeInstall, + Status: &fleet.MDMAppleDeliveryFailed, + Checksum: []byte("checksum"), + }, + })) + + // Check that BitLocker summary does not include macOS hosts + checkBitLockerSummary(t, nil, fleet.MDMWindowsBitLockerSummary{ + Verified: 1, + Verifying: 0, + Failed: 1, + Enforcing: 1, + RemovingEnforcement: 0, + ActionRequired: 0, + }) + + // Check that filtered lists do include macOS hosts + checkListHostsFilterDiskEncryption(t, nil, fleet.DiskEncryptionFailed, []uint{hosts[1].ID, hosts[5].ID}) + checkListHostsFilterOSSettings(t, nil, fleet.OSSettingsFailed, []uint{hosts[1].ID, hosts[5].ID}) + }) + }) } diff --git a/server/datastore/mysql/migrations/tables/20231004144339_MoveDiskEncryptionSetting.go b/server/datastore/mysql/migrations/tables/20231004144339_MoveDiskEncryptionSetting.go new file mode 100644 index 000000000000..656e68da6f3b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20231004144339_MoveDiskEncryptionSetting.go @@ -0,0 +1,32 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20231004144339, Down_20231004144339) +} + +func Up_20231004144339(tx *sql.Tx) error { + stmt := ` +UPDATE teams +SET + config = JSON_SET(config, '$.mdm.enable_disk_encryption', + JSON_EXTRACT(config, '$.mdm.macos_settings.enable_disk_encryption')), + config = JSON_REMOVE(config, '$.mdm.macos_settings.enable_disk_encryption') +WHERE + JSON_EXTRACT(config, '$.mdm.macos_settings.enable_disk_encryption') IS NOT NULL; + ` + + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("move team mdm.macos_settings.enable_disk_encryption setting to mdm.enable_disk_encryption: %w", err) + } + + return nil +} + +func Down_20231004144339(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20231004144339_MoveDiskEncryptionSetting_test.go b/server/datastore/mysql/migrations/tables/20231004144339_MoveDiskEncryptionSetting_test.go new file mode 100644 index 000000000000..78e76005376c --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20231004144339_MoveDiskEncryptionSetting_test.go @@ -0,0 +1,67 @@ +package tables + +import ( + "encoding/json" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestUp_20231004144339(t *testing.T) { + db := applyUpToPrev(t) + + dataStmts := ` + INSERT INTO teams VALUES + (1,'2023-07-21 20:32:42','Team 1','','{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null, \"enable_disk_encryption\": false}}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": true}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"agent_options\": {\"config\": {\"options\": {\"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"webhook_settings\": {\"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}}'), + (2,'2023-07-21 20:32:47','Team 2','','{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null, \"enable_disk_encryption\": true}}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": true}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"agent_options\": {\"config\": {\"options\": {\"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"webhook_settings\": {\"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}}'); + ` + _, err := db.Exec(dataStmts) + require.NoError(t, err) + + var rawConfigs []json.RawMessage + err = sqlx.Select(db, &rawConfigs, "SELECT config FROM teams ORDER BY id") + require.NoError(t, err) + + var wantConfigs []map[string]any + for _, c := range rawConfigs { + var wantConfig map[string]any + err = json.Unmarshal(c, &wantConfig) + require.NoError(t, err) + wantConfigs = append(wantConfigs, wantConfig) + } + + applyNext(t, db) + + rawConfigs = []json.RawMessage{} + err = sqlx.Select(db, &rawConfigs, "SELECT JSON_EXTRACT(config, '$') FROM teams ORDER BY id") + require.NoError(t, err) + + var gotConfigs []map[string]any + for _, c := range rawConfigs { + var gotConfig map[string]any + err = json.Unmarshal(c, &gotConfig) + require.NoError(t, err) + gotConfigs = append(gotConfigs, gotConfig) + } + + // simulate the ideal behavior with the oldConfigs + for i, config := range wantConfigs { + if mdmMap, ok := config["mdm"].(map[string]interface{}); ok { + // Delete 'mdm.macos_settings.enable_disk_encryption' + if macosSettings, ok := mdmMap["macos_settings"].(map[string]interface{}); ok { + delete(macosSettings, "enable_disk_encryption") + } + + // Set 'mdm.enable_disk_encryption' + if i == 0 { + mdmMap["enable_disk_encryption"] = false + } else { + mdmMap["enable_disk_encryption"] = true + } + } + wantConfigs[i] = config + } + + require.ElementsMatch(t, wantConfigs, gotConfigs) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index de39bc7e499f..caa350e5a7c7 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -40,7 +40,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null, \"enable_disk_encryption\": false}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `carve_blocks` ( @@ -685,9 +685,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=211 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=212 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231002120317,1,'2020-01-01 01:01:01'),(210,20231004144338,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231002120317,1,'2020-01-01 01:01:01'),(210,20231004144338,1,'2020-01-01 01:01:01'),(211,20231004144339,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/app.go b/server/fleet/app.go index 2b9ce6503fd0..e0f2b6a966f2 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -13,7 +13,9 @@ import ( "time" "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/ptr" ) // SMTP settings names returned from API, these map to SMTPAuthType and @@ -157,12 +159,23 @@ type MDM struct { // with the similarly named macOS-specific fields. WindowsEnabledAndConfigured bool `json:"windows_enabled_and_configured"` + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + ///////////////////////////////////////////////////////////////// // WARNING: If you add to this struct make sure it's taken into // account in the AppConfig Clone implementation! ///////////////////////////////////////////////////////////////// } +// AtLeastOnePlatformEnabledAndConfigured returns true if at least one supported platform +// (macOS or Windows) has MDM enabled and configured. +func (m MDM) AtLeastOnePlatformEnabledAndConfigured() bool { + // explicitly check for the feature flag to account for the edge case of: + // 1. FF enabled, windows is turned on + // 2. FF disabled on server restart + return m.EnabledAndConfigured || (config.IsMDMFeatureFlagEnabled() && m.WindowsEnabledAndConfigured) +} + // versionStringRegex is used to validate that a version string is in the x.y.z // format only (no prerelease or build metadata). var versionStringRegex = regexp.MustCompile(`^\d+(\.\d+)?(\.\d+)?$`) @@ -222,10 +235,8 @@ type MacOSSettings struct { // // NOTE: These are only present here for informational purposes. // (The source of truth for profiles is in MySQL.) - CustomSettings []string `json:"custom_settings"` - // EnableDiskEncryption enables disk encryption on hosts such that the hosts' - // disk encryption keys will be stored in Fleet. - EnableDiskEncryption bool `json:"enable_disk_encryption"` + CustomSettings []string `json:"custom_settings"` + DeprecatedEnableDiskEncryption *bool `json:"enable_disk_encryption,omitempty"` // NOTE: make sure to update the ToMap/FromMap methods when adding/updating fields. } @@ -233,7 +244,7 @@ type MacOSSettings struct { func (s MacOSSettings) ToMap() map[string]interface{} { return map[string]interface{}{ "custom_settings": s.CustomSettings, - "enable_disk_encryption": s.EnableDiskEncryption, + "enable_disk_encryption": s.DeprecatedEnableDiskEncryption, } } @@ -274,11 +285,11 @@ func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, erro // error, must be a bool return nil, &json.UnmarshalTypeError{ Value: fmt.Sprintf("%T", v), - Type: reflect.TypeOf(s.EnableDiskEncryption), + Type: reflect.TypeOf(s.DeprecatedEnableDiskEncryption).Elem(), Field: "macos_settings.enable_disk_encryption", } } - s.EnableDiskEncryption = b + s.DeprecatedEnableDiskEncryption = ptr.Bool(b) } return set, nil @@ -344,7 +355,8 @@ type AppConfig struct { SMTPSettings *SMTPSettings `json:"smtp_settings,omitempty"` HostExpirySettings HostExpirySettings `json:"host_expiry_settings"` // Features allows to globally enable or disable features - Features Features `json:"features"` + Features Features `json:"features"` + DeprecatedHostSettings *Features `json:"host_settings,omitempty"` // AgentOptions holds osquery configuration. // // This field is a pointer to avoid returning this information to non-global-admins. @@ -392,12 +404,6 @@ func (c *AppConfig) Obfuscate() { } } -// legacyConfig holds settings that have been replaced, superceded or -// deprecated by other AppConfig settings. -type legacyConfig struct { - HostSettings *Features `json:"host_settings"` -} - // Clone implements cloner. func (c *AppConfig) Clone() (interface{}, error) { return c.Copy(), nil @@ -509,6 +515,31 @@ func (e *EnrichedAppConfig) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON implements the json.Marshaler interface to make sure we serialize +// both AppConfig and enrichedAppConfigFields properly: +// +// - If this function is not defined, AppConfig.MarshalJSON gets promoted and +// will be called instead. +// - If we try to unmarshal everything in one go, AppConfig.MarshalJSON doesn't get +// called. +func (e *EnrichedAppConfig) MarshalJSON() ([]byte, error) { + // Marshal only the enriched fields + enrichedData, err := json.Marshal(e.enrichedAppConfigFields) + if err != nil { + return nil, err + } + + // Marshal the base AppConfig + appConfigData, err := json.Marshal(e.AppConfig) + if err != nil { + return nil, err + } + + // we need to marshal and combine both groups separately because + // AppConfig has a custom marshaler. + return rawjson.CombineRoots(enrichedData, appConfigData) +} + type Duration struct { time.Duration } @@ -628,16 +659,13 @@ func (c *AppConfig) DidUnmarshalLegacySettings() []string { return c.didUnmarsha func (c *AppConfig) UnmarshalJSON(b []byte) error { // Define a new type, this is to prevent infinite recursion when // unmarshalling the AppConfig struct. - type cfgStructUnmarshal AppConfig + type aliasConfig AppConfig compatConfig := struct { - *legacyConfig - *cfgStructUnmarshal + *aliasConfig }{ - &legacyConfig{}, - (*cfgStructUnmarshal)(c), + (*aliasConfig)(c), } - c.didUnmarshalLegacySettings = nil decoder := json.NewDecoder(bytes.NewReader(b)) if c.strictDecoding { decoder.DisallowUnknownFields() @@ -649,16 +677,56 @@ func (c *AppConfig) UnmarshalJSON(b []byte) error { return errors.New("unexpected extra tokens found in config") } + c.assignDeprecatedFields() + + return nil +} + +func (c AppConfig) MarshalJSON() ([]byte, error) { + // Define a new type, this is to prevent infinite recursion when + // marshalling the AppConfig struct. + c.assignDeprecatedFields() + + // requirements are that if this value is not set, defaults to false. + // The default mashaler of optjson.Bool will convert this to `null` if + // it's not valid. + if !c.MDM.EnableDiskEncryption.Valid { + c.MDM.EnableDiskEncryption = optjson.SetBool(false) + } + + type aliasConfig AppConfig + aa := aliasConfig(c) + return json.Marshal(aa) +} + +func (c *AppConfig) assignDeprecatedFields() { + c.didUnmarshalLegacySettings = nil // Define and assign legacy settings to new fields. // This has the drawback of legacy fields taking precedence over new fields // if both are defined. - if compatConfig.legacyConfig.HostSettings != nil { + // + // TODO: with optjson + the new approach we're using to handle legacy + // fields, legacy fields don't have to take precedence over new fields. + // Is it worth changing this behavior for `host_settings`/`features` at this point? + if c.DeprecatedHostSettings != nil { c.didUnmarshalLegacySettings = append(c.didUnmarshalLegacySettings, "host_settings") - c.Features = *compatConfig.legacyConfig.HostSettings + c.Features = *c.DeprecatedHostSettings } - sort.Strings(c.didUnmarshalLegacySettings) - return nil + // if disk encryption is not set in the root config + // try to read the value from the legacy config + if !c.MDM.EnableDiskEncryption.Valid { + if c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption != nil { + c.didUnmarshalLegacySettings = append(c.didUnmarshalLegacySettings, "mdm.macos_settings.enable_disk_encryption") + c.MDM.EnableDiskEncryption = optjson.SetBool(*c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption) + } + } + + // ensure the legacy configs are always nil + c.DeprecatedHostSettings = nil + c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption = nil + + sort.Strings(c.didUnmarshalLegacySettings) } // OrgInfo contains general info about the organization using Fleet. diff --git a/server/fleet/app_test.go b/server/fleet/app_test.go index 268eb2277ae8..bc7a4173d3b7 100644 --- a/server/fleet/app_test.go +++ b/server/fleet/app_test.go @@ -1,6 +1,7 @@ package fleet import ( + "encoding/json" "testing" "github.com/fleetdm/fleet/v4/pkg/optjson" @@ -159,3 +160,154 @@ func TestMacOSMigrationModeIsValid(t *testing.T) { require.False(t, (MacOSMigrationMode("")).IsValid()) require.False(t, (MacOSMigrationMode("foo")).IsValid()) } + +func TestAppConfigDeprecatedFields(t *testing.T) { + cases := []struct { + msg string + in json.RawMessage + wantFeatures Features + wantDiskEncryption bool + }{ + {"both empty", json.RawMessage(`{}`), Features{}, false}, + {"only one feature set", json.RawMessage(`{"host_settings": {"enable_host_users": true}}`), Features{EnableHostUsers: true}, false}, + { + "a feature and disk encryption set", + json.RawMessage(`{"host_settings": {"enable_host_users": true}, "mdm": {"macos_settings": {"enable_disk_encryption": true}}}`), + Features{EnableHostUsers: true}, + true, + }, + { + "features legacy and new setting set", + json.RawMessage(`{"host_settings": {"enable_host_users": true}, "features": {"enable_host_users": false}}`), + Features{EnableHostUsers: true}, + false, + }, + { + "disk encryption legacy and new setting set", + json.RawMessage(`{"mdm": {"enable_disk_encryption": false, "macos_settings": {"enable_disk_encryption": true}}}`), + Features{}, + false, + }, + } + + for _, c := range cases { + t.Run(c.msg, func(t *testing.T) { + ac := AppConfig{} + err := json.Unmarshal(c.in, &ac) + require.NoError(t, err) + require.Nil(t, ac.DeprecatedHostSettings) + require.Nil(t, ac.MDM.MacOSSettings.DeprecatedEnableDiskEncryption) + require.Equal(t, c.wantFeatures, ac.Features) + require.Equal(t, c.wantDiskEncryption, ac.MDM.EnableDiskEncryption.Value) + + // marshalling the fields again doesn't contain deprecated fields + acJSON, err := json.Marshal(ac) + require.NoError(t, err) + var resultMap map[string]interface{} + err = json.Unmarshal(acJSON, &resultMap) + require.NoError(t, err) + + // host_settings is not present + _, exists := resultMap["host_settings"] + require.False(t, exists) + + // mdm.macos_settings.enable_disk_encryption is not present + mdm, ok := resultMap["mdm"].(map[string]interface{}) + require.True(t, ok) + macosSettings, ok := mdm["macos_settings"].(map[string]interface{}) + require.True(t, ok) + _, exists = macosSettings["enable_disk_encryption"] + require.False(t, exists) + + diskEncryption, exists := mdm["enable_disk_encryption"] + require.True(t, exists) + require.EqualValues(t, c.wantDiskEncryption, diskEncryption) + + }) + } + +} + +func TestAtLeastOnePlatformEnabledAndConfigured(t *testing.T) { + tests := []struct { + name string + macOSEnabledAndConfigured bool + windowsEnabledAndConfigured bool + isMDMFeatureFlagEnabled bool + expectedResult bool + }{ + { + name: "None enabled, feature flag disabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: false, + expectedResult: false, + }, + { + name: "MacOS enabled, feature flag disabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: false, + expectedResult: true, + }, + { + name: "Windows enabled, feature flag disabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: false, + expectedResult: false, + }, + { + name: "Both enabled, feature flag disabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: false, + expectedResult: true, + }, + { + name: "None enabled, feature flag enabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: true, + expectedResult: false, + }, + { + name: "MacOS enabled, feature flag enabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: true, + expectedResult: true, + }, + { + name: "Windows enabled, feature flag enabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: true, + expectedResult: true, + }, + { + name: "Both enabled, feature flag enabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: true, + expectedResult: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.isMDMFeatureFlagEnabled { + t.Setenv("FLEET_DEV_MDM_ENABLED", "1") + } else { + t.Setenv("FLEET_DEV_MDM_ENABLED", "0") + } + + mdm := MDM{ + EnabledAndConfigured: test.macOSEnabledAndConfigured, + WindowsEnabledAndConfigured: test.windowsEnabledAndConfigured, + } + result := mdm.AtLeastOnePlatformEnabledAndConfigured() + require.Equal(t, test.expectedResult, result) + }) + } +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 3dc7c9038343..894d39ab8ef3 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -705,12 +705,12 @@ type Datastore interface { SetOrUpdateHostDisksEncryption(ctx context.Context, hostID uint, encrypted bool) error // SetOrUpdateHostDiskEncryptionKey sets the base64, encrypted key for // a host - SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string) error + SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key, clientError string, decryptable *bool) error // GetUnverifiedDiskEncryptionKeys returns all the encryption keys that // are collected but their decryptable status is not known yet (ie: // we're able to decrypt the key using a private key in the server) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]HostDiskEncryptionKey, error) - // SetHostDiskEncryptionKeyStatus sets the encryptable status for the set + // SetHostsDiskEncryptionKeyStatus sets the encryptable status for the set // of encription keys provided SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error // GetHostDiskEncryptionKey returns the encryption key information for a given host @@ -1034,12 +1034,26 @@ type Datastore interface { // WSTEPAssociateCertHash associates a certificate hash with a device. WSTEPAssociateCertHash(ctx context.Context, deviceUUID string, hash string) error - // MDMWindowsGetEnrolledDevice receives a Windows MDM device id and returns the device information. + // MDMWindowsGetEnrolledDevice receives a Windows MDM HW device id and returns the device information. MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceID string) (*MDMWindowsEnrolledDevice, error) // MDMWindowsInsertEnrolledDevice inserts a new MDMWindowsEnrolledDevice in the database MDMWindowsInsertEnrolledDevice(ctx context.Context, device *MDMWindowsEnrolledDevice) error - // MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the device id. + // MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the HW device id. MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceID string) error + // MDMWindowsGetEnrolledDeviceWithDeviceID receives a Windows MDM device id and returns the device information + MDMWindowsGetEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) (*MDMWindowsEnrolledDevice, error) + // MDMWindowsDeleteEnrolledDeviceWithDeviceID deletes a give MDMWindowsEnrolledDevice entry from the database using the device id + MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error + + // GetMDMWindowsBitLockerSummary summarizes the current state of Windows disk encryption on + // each Windows host in the specified team (or, if no team is specified, each host that is not assigned + // to any team). + GetMDMWindowsBitLockerSummary(ctx context.Context, teamID *uint) (*MDMWindowsBitLockerSummary, error) + // GetMDMWindowsBitLockerStatus returns the disk encryption status for a given host + // + // Note that the returned status will be nil if the host is reported to be a Windows + // server or if disk encryption is disabled for the host's team (or no team, as applicable). + GetMDMWindowsBitLockerStatus(ctx context.Context, host *Host) (*DiskEncryptionStatus, error) /////////////////////////////////////////////////////////////////////////////// // Host Script Results diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 87a401f71b32..9e22fa92c3ba 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -53,20 +53,20 @@ const ( MDMEnrollStatusEnrolled = MDMEnrollStatus("enrolled") // combination of "manual" and "automatic" ) -// MacOSSettingsStatus defines the possible statuses of the host's macOS settings, which is derived from the -// status of MDM configuration profiles applied to the host. -type MacOSSettingsStatus string +// OSSettingsStatus defines the possible statuses of the host's OS settings, which is derived from the +// status of MDM configuration profiles and non-profile settings applied the host. +type OSSettingsStatus string const ( - MacOSSettingsVerified MacOSSettingsStatus = "verified" - MacOSSettingsVerifying MacOSSettingsStatus = "verifying" - MacOSSettingsPending MacOSSettingsStatus = "pending" - MacOSSettingsFailed MacOSSettingsStatus = "failed" + OSSettingsVerified OSSettingsStatus = "verified" + OSSettingsVerifying OSSettingsStatus = "verifying" + OSSettingsPending OSSettingsStatus = "pending" + OSSettingsFailed OSSettingsStatus = "failed" ) -func (s MacOSSettingsStatus) IsValid() bool { +func (s OSSettingsStatus) IsValid() bool { switch s { - case MacOSSettingsFailed, MacOSSettingsPending, MacOSSettingsVerifying, MacOSSettingsVerified: + case OSSettingsFailed, OSSettingsPending, OSSettingsVerifying, OSSettingsVerified: return true default: return false @@ -139,12 +139,19 @@ type HostListOptions struct { // MacOSSettingsFilter filters the hosts by the status of MDM configuration profiles // applied to the hosts. - MacOSSettingsFilter MacOSSettingsStatus + MacOSSettingsFilter OSSettingsStatus // MacOSSettingsDiskEncryptionFilter filters the hosts by the status of the disk encryption // MDM profile. MacOSSettingsDiskEncryptionFilter DiskEncryptionStatus + // OSSettingsFilter filters the hosts by the status of MDM configuration profiles and + // non-profile settings applied to the hosts. + OSSettingsFilter OSSettingsStatus + // OSSettingsDiskEncryptionFilter filters the hosts by the status of the disk encryption + // OS setting. + OSSettingsDiskEncryptionFilter DiskEncryptionStatus + // MDMBootstrapPackageFilter filters the hosts by the status of the MDM bootstrap package. MDMBootstrapPackageFilter *MDMBootstrapPackageStatus @@ -186,7 +193,9 @@ func (h HostListOptions) Empty() bool { h.MDMNameFilter == nil && h.MDMEnrollmentStatusFilter == "" && h.MunkiIssueIDFilter == nil && - h.LowDiskSpaceFilter == nil + h.LowDiskSpaceFilter == nil && + h.OSSettingsFilter == "" && + h.OSSettingsDiskEncryptionFilter == "" } type HostUser struct { @@ -336,6 +345,12 @@ type MDMHostData struct { // gets filled. rawDecryptable *int + // OSSettings contains information related to operating systems settings that are managed for + // MDM-enrolled hosts. + // + // Note: Additional information for macOS hosts is currently stored in MacOSSettings. + OSSettings *HostMDMOSSettings `json:"os_settings,omitempty" db:"-" csv:"-"` + // Profiles is a list of HostMDMProfiles for the host. Note that as for many // other host fields, it is not filled in by all host-returning datastore methods. // @@ -358,6 +373,14 @@ type MDMHostData struct { MacOSSetup *HostMDMMacOSSetup `json:"macos_setup,omitempty" db:"-" csv:"-"` } +type HostMDMOSSettings struct { + DiskEncryption HostMDMDiskEncryption `json:"disk_encryption" db:"-" csv:"-"` +} + +type HostMDMDiskEncryption struct { + Status *DiskEncryptionStatus `json:"status" db:"-" csv:"-"` +} + type DiskEncryptionStatus string const ( @@ -411,11 +434,11 @@ type HostMDMMacOSSetup struct { BootstrapPackageName string `db:"bootstrap_package_name" json:"bootstrap_package_name" csv:"-"` } -// DetermineDiskEncryptionStatus determines the disk encryption status for the +// DetermineMacOSDiskEncryptionStatus determines the disk encryption status for the // host based on the file-vault profile in its list of profiles and whether its // disk encryption key is available and decryptable. The file-vault profile // identifier is received as argument to avoid a circular dependency. -func (d *MDMHostData) DetermineDiskEncryptionStatus(profiles []HostMDMAppleProfile, fileVaultIdentifier string) { +func (d *MDMHostData) DetermineMacOSDiskEncryptionStatus(profiles []HostMDMAppleProfile, fileVaultIdentifier string) { var settings MDMHostMacOSSettings var fvprof *HostMDMAppleProfile @@ -577,6 +600,24 @@ func (h *Host) IsEligibleForWindowsMDMUnenrollment() bool { (h.MDMInfo == nil || !h.MDMInfo.IsServer) } +// IsEligibleForBitLockerEncryption checks if the host needs to enforce disk +// encryption using Fleet MDM features. +// +// Note: the *Host structs needs disk encryption data and MDM data filled in to +// perform the check. +func (h *Host) IsEligibleForBitLockerEncryption() bool { + isServer := h.MDMInfo != nil && h.MDMInfo.IsServer + isWindows := h.FleetPlatform() == "windows" + needsEncryption := h.DiskEncryptionEnabled != nil && !*h.DiskEncryptionEnabled + encryptedWithoutKey := h.DiskEncryptionEnabled != nil && *h.DiskEncryptionEnabled && !h.MDM.EncryptionKeyAvailable + + return isWindows && + h.IsOsqueryEnrolled() && + h.MDMInfo.IsFleetEnrolled() && + !isServer && + (needsEncryption || encryptedWithoutKey) +} + // DisplayName returns ComputerName if it isn't empty. Otherwise, it returns Hostname if it isn't // empty. If Hostname is empty and both HardwareSerial and HardwareModel are not empty, it returns a // composite string with HardwareModel and HardwareSerial. If all else fails, it returns an empty @@ -829,6 +870,9 @@ func (h *HostMDM) IsManualFleetEnrolled() bool { // it is in enrolled state for Fleet MDM, regardless of automatic or manual // enrollment method. func (h *HostMDM) IsFleetEnrolled() bool { + if h == nil { + return false + } return h.IsDEPFleetEnrolled() || h.IsManualFleetEnrolled() } diff --git a/server/fleet/hosts_test.go b/server/fleet/hosts_test.go index 2458e8d7f5ef..2bba30a7d785 100644 --- a/server/fleet/hosts_test.go +++ b/server/fleet/hosts_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/WatchBeam/clock" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -240,3 +241,53 @@ func TestIsDEPCapable(t *testing.T) { require.Equal(t, tc.expected, tc.hostMDM.IsDEPCapable()) } } + +func TestIsEligibleForBitLockerEncryption(t *testing.T) { + require.False(t, (&Host{}).IsEligibleForBitLockerEncryption()) + + hostThatNeedsEnforcement := Host{ + Platform: "windows", + OsqueryHostID: ptr.String("test"), + MDMInfo: &HostMDM{ + Name: WellKnownMDMFleet, + Enrolled: true, + IsServer: false, + InstalledFromDep: true, + }, + MDM: MDMHostData{ + EncryptionKeyAvailable: false, + }, + DiskEncryptionEnabled: ptr.Bool(false), + } + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // macOS hosts are not elegible + hostThatNeedsEnforcement.Platform = "darwin" + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.Platform = "windows" + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // hosts with disk encryption already enabled are elegible only if we + // can't decrypt the key + hostThatNeedsEnforcement.DiskEncryptionEnabled = ptr.Bool(true) + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.MDM.EncryptionKeyAvailable = true + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + hostThatNeedsEnforcement.DiskEncryptionEnabled = ptr.Bool(false) + hostThatNeedsEnforcement.MDM.EncryptionKeyAvailable = false + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // hosts without MDMinfo are not elegible + oldMDMInfo := hostThatNeedsEnforcement.MDMInfo + hostThatNeedsEnforcement.MDMInfo = nil + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.MDMInfo = oldMDMInfo + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // hosts that are not enrolled in MDM are not elegible + hostThatNeedsEnforcement.MDMInfo.Enrolled = false + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.MDMInfo.Enrolled = true + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) +} diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 9bac69829929..9a68a6d6b68d 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -135,3 +135,16 @@ type HostMDMProfileRetryCount struct { ProfileIdentifier string `db:"profile_identifier"` Retries uint `db:"retries"` } + +type MDMPlatformsCounts struct { + MacOS uint `db:"macos" json:"macos"` + Windows uint `db:"windows" json:"windows"` +} +type MDMDiskEncryptionSummary struct { + Verified MDMPlatformsCounts `db:"verified" json:"verified"` + Verifying MDMPlatformsCounts `db:"verifying" json:"verifying"` + ActionRequired MDMPlatformsCounts `db:"action_required" json:"action_required"` + Enforcing MDMPlatformsCounts `db:"enforcing" json:"enforcing"` + Failed MDMPlatformsCounts `db:"failed" json:"failed"` + RemovingEnforcement MDMPlatformsCounts `db:"removing_enforcement" json:"removing_enforcement"` +} diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go index 77ecd683f028..b8c2b2fb6586 100644 --- a/server/fleet/orbit.go +++ b/server/fleet/orbit.go @@ -29,6 +29,10 @@ type OrbitConfigNotifications struct { // execution on that host. The scripts pending execution are those that // haven't received a result yet. PendingScriptExecutionIDs []string `json:"pending_script_execution_ids,omitempty"` + + // EnforceBitLockerEncryption is sent as true if Windows MDM is + // enabled and the device should encrypt its disk volumes with BitLocker. + EnforceBitLockerEncryption bool `json:"enforce_bitlocker_encryption,omitempty"` } type OrbitConfig struct { @@ -81,3 +85,9 @@ func (es *Extensions) FilterByHostPlatform(hostPlatform string) { } } } + +// OrbitHostDiskEncryptionKeyPayload contains the disk encryption key for a host. +type OrbitHostDiskEncryptionKeyPayload struct { + EncryptionKey []byte `json:"encryption_key"` + ClientError string `json:"client_error"` +} diff --git a/server/fleet/service.go b/server/fleet/service.go index 8967dc04089b..bfbcb57cb6cd 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -714,6 +714,11 @@ type Service interface { // error can be raised to the user. VerifyMDMWindowsConfigured(ctx context.Context) error + // VerifyMDMAppleOrWindowsConfigured verifies that the server is configured + // for either Apple or Windows MDM. If an error is returned, authorization is + // skipped so the error can be raised to the user. + VerifyMDMAppleOrWindowsConfigured(ctx context.Context) error + MDMAppleUploadBootstrapPackage(ctx context.Context, name string, pkg io.Reader, teamID uint) error GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*MDMAppleBootstrapPackage, error) @@ -793,6 +798,17 @@ type Service interface { // GetMDMWindowsTOSContent returns TOS content GetMDMWindowsTOSContent(ctx context.Context, redirectUri string, reqID string) (string, error) + // Set or update the disk encryption key for a host. + SetOrUpdateDiskEncryptionKey(ctx context.Context, encryptionKey, clientError string) error + + /////////////////////////////////////////////////////////////////////////////// + // Common MDM + + // GetMDMDiskEncryptionSummary returns the current disk encryption status of all macOS and + // Windows hosts in the specified team (or, if no team is specified, each host that is not + // assigned to any team). + GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*MDMDiskEncryptionSummary, error) + /////////////////////////////////////////////////////////////////////////////// // Host Script Execution diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 4c6ba72e035a..191ef0f3b1e7 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "time" + + "github.com/fleetdm/fleet/v4/pkg/optjson" ) const ( @@ -29,9 +31,10 @@ type TeamPayload struct { // need to be able which part of the MDM config was provided in the request, // so the fields are pointers to structs. type TeamPayloadMDM struct { - MacOSUpdates *MacOSUpdates `json:"macos_updates"` - MacOSSettings *MacOSSettings `json:"macos_settings"` - MacOSSetup *MacOSSetup `json:"macos_setup"` + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + MacOSUpdates *MacOSUpdates `json:"macos_updates"` + MacOSSettings *MacOSSettings `json:"macos_settings"` + MacOSSetup *MacOSSetup `json:"macos_setup"` } // Team is the data representation for the "Team" concept (group of hosts and @@ -143,13 +146,16 @@ type TeamWebhookSettings struct { } type TeamMDM struct { - MacOSUpdates MacOSUpdates `json:"macos_updates"` - MacOSSettings MacOSSettings `json:"macos_settings"` - MacOSSetup MacOSSetup `json:"macos_setup"` + EnableDiskEncryption bool `json:"enable_disk_encryption"` + MacOSUpdates MacOSUpdates `json:"macos_updates"` + MacOSSettings MacOSSettings `json:"macos_settings"` + MacOSSetup MacOSSetup `json:"macos_setup"` // NOTE: TeamSpecMDM must be kept in sync with TeamMDM. } type TeamSpecMDM struct { + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + MacOSUpdates MacOSUpdates `json:"macos_updates"` // A map is used for the macos settings so that we can easily detect if its @@ -364,7 +370,9 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { var mdmSpec TeamSpecMDM mdmSpec.MacOSUpdates = t.Config.MDM.MacOSUpdates mdmSpec.MacOSSettings = t.Config.MDM.MacOSSettings.ToMap() + delete(mdmSpec.MacOSSettings, "enable_disk_encryption") mdmSpec.MacOSSetup = t.Config.MDM.MacOSSetup + mdmSpec.EnableDiskEncryption = optjson.SetBool(t.Config.MDM.EnableDiskEncryption) return &TeamSpec{ Name: t.Name, AgentOptions: agentOptions, diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go new file mode 100644 index 000000000000..0a72f5aea74a --- /dev/null +++ b/server/fleet/windows_mdm.go @@ -0,0 +1,16 @@ +package fleet + +// MDMWindowsBitLockerSummary reports the number of Windows hosts being managed by Fleet with +// BitLocker. Each host may be counted in only one of six mutually-exclusive categories: +// Verified, Verifying, ActionRequired, Enforcing, Failed, RemovingEnforcement. +// +// Note that it is expected that each of Verifying, ActionRequired, and RemovingEnforcement will be +// zero because these states are not in Fleet's current implementation of BitLocker management. +type MDMWindowsBitLockerSummary struct { + Verified uint `json:"verified" db:"verified"` + Verifying uint `json:"verifying" db:"verifying"` + ActionRequired uint `json:"action_required" db:"action_required"` + Enforcing uint `json:"enforcing" db:"enforcing"` + Failed uint `json:"failed" db:"failed"` + RemovingEnforcement uint `json:"removing_enforcement" db:"removing_enforcement"` +} diff --git a/server/mdm/apple/cert.go b/server/mdm/apple/cert.go index 8b06ded4d8ec..aa300c459691 100644 --- a/server/mdm/apple/cert.go +++ b/server/mdm/apple/cert.go @@ -2,12 +2,10 @@ package apple_mdm import ( "bytes" - "crypto" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" - "encoding/base64" "encoding/json" "fmt" "io/ioutil" @@ -17,7 +15,6 @@ import ( "github.com/micromdm/nanodep/tokenpki" "github.com/micromdm/scep/v2/depot" - "go.mozilla.org/pkcs7" ) const ( @@ -160,17 +157,3 @@ func NewDEPKeyPairPEM() ([]byte, []byte, error) { return publicKeyPEM, privateKeyPEM, nil } - -func DecryptBase64CMS(p7Base64 string, cert *x509.Certificate, key crypto.PrivateKey) ([]byte, error) { - p7Bytes, err := base64.StdEncoding.DecodeString(p7Base64) - if err != nil { - return nil, err - } - - p7, err := pkcs7.Parse(p7Bytes) - if err != nil { - return nil, err - } - - return p7.Decrypt(cert, key) -} diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go new file mode 100644 index 000000000000..79a55dd50ab9 --- /dev/null +++ b/server/mdm/mdm.go @@ -0,0 +1,25 @@ +package mdm + +import ( + "crypto" + "crypto/x509" + "encoding/base64" + + "go.mozilla.org/pkcs7" +) + +// DecryptBase64CMS decrypts a base64 encoded pkcs7-encrypted value using the +// provided certificate and private key. +func DecryptBase64CMS(p7Base64 string, cert *x509.Certificate, key crypto.PrivateKey) ([]byte, error) { + p7Bytes, err := base64.StdEncoding.DecodeString(p7Base64) + if err != nil { + return nil, err + } + + p7, err := pkcs7.Parse(p7Bytes) + if err != nil { + return nil, err + } + + return p7.Decrypt(cert, key) +} diff --git a/server/mdm/apple/cert_test.go b/server/mdm/mdm_test.go similarity index 99% rename from server/mdm/apple/cert_test.go rename to server/mdm/mdm_test.go index f6289e99238d..35f151ac1d98 100644 --- a/server/mdm/apple/cert_test.go +++ b/server/mdm/mdm_test.go @@ -1,4 +1,4 @@ -package apple_mdm +package mdm import ( "crypto/tls" diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go index 552c8ffc97a2..8a52f6bc378d 100644 --- a/server/mdm/microsoft/microsoft_mdm.go +++ b/server/mdm/microsoft/microsoft_mdm.go @@ -1,7 +1,11 @@ package microsoft_mdm import ( + "crypto/x509" + "encoding/base64" + "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" + "go.mozilla.org/pkcs7" ) const ( @@ -174,7 +178,7 @@ const ( WstepRenewRetryInterval = "4" // The PROVIDER-ID paramer specifies the server identifier for a management server used in the current management session - DocProvisioningAppProviderID = "FleetDM" + DocProvisioningAppProviderID = "Fleet" // The NAME parameter is used in the APPLICATION characteristic to specify a user readable application identity DocProvisioningAppName = DocProvisioningAppProviderID @@ -275,3 +279,14 @@ func ResolveWindowsMDMAuth(serverURL string) (string, error) { func ResolveWindowsMDMManagement(serverURL string) (string, error) { return commonmdm.ResolveURL(serverURL, MDE2ManagementPath, false) } + +// Encrypt uses pkcs7 to encrypt a raw value using the provided certificate. +// The returned encrypted value is base64-encoded. +func Encrypt(rawValue string, cert *x509.Certificate) (string, error) { + encrypted, err := pkcs7.Encrypt([]byte(rawValue), []*x509.Certificate{cert}) + if err != nil { + return "", err + } + b64Enc := base64.StdEncoding.EncodeToString(encrypted) + return b64Enc, nil +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 7a3afc3823eb..937f58dadc6c 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -490,7 +490,7 @@ type SetOrUpdateHostDisksSpaceFunc func(ctx context.Context, hostID uint, gigsAv type SetOrUpdateHostDisksEncryptionFunc func(ctx context.Context, hostID uint, encrypted bool) error -type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string) error +type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string, clientError string, decryptable *bool) error type GetUnverifiedDiskEncryptionKeysFunc func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) @@ -684,6 +684,14 @@ type MDMWindowsInsertEnrolledDeviceFunc func(ctx context.Context, device *fleet. type MDMWindowsDeleteEnrolledDeviceFunc func(ctx context.Context, mdmDeviceID string) error +type MDMWindowsGetEnrolledDeviceWithDeviceIDFunc func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) + +type MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc func(ctx context.Context, mdmDeviceID string) error + +type GetMDMWindowsBitLockerSummaryFunc func(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) + +type GetMDMWindowsBitLockerStatusFunc func(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) + type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) error @@ -1692,6 +1700,18 @@ type DataStore struct { MDMWindowsDeleteEnrolledDeviceFunc MDMWindowsDeleteEnrolledDeviceFunc MDMWindowsDeleteEnrolledDeviceFuncInvoked bool + MDMWindowsGetEnrolledDeviceWithDeviceIDFunc MDMWindowsGetEnrolledDeviceWithDeviceIDFunc + MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked bool + + MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc + MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked bool + + GetMDMWindowsBitLockerSummaryFunc GetMDMWindowsBitLockerSummaryFunc + GetMDMWindowsBitLockerSummaryFuncInvoked bool + + GetMDMWindowsBitLockerStatusFunc GetMDMWindowsBitLockerStatusFunc + GetMDMWindowsBitLockerStatusFuncInvoked bool + NewHostScriptExecutionRequestFunc NewHostScriptExecutionRequestFunc NewHostScriptExecutionRequestFuncInvoked bool @@ -3359,11 +3379,11 @@ func (s *DataStore) SetOrUpdateHostDisksEncryption(ctx context.Context, hostID u return s.SetOrUpdateHostDisksEncryptionFunc(ctx, hostID, encrypted) } -func (s *DataStore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string) error { +func (s *DataStore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string, clientError string, decryptable *bool) error { s.mu.Lock() s.SetOrUpdateHostDiskEncryptionKeyFuncInvoked = true s.mu.Unlock() - return s.SetOrUpdateHostDiskEncryptionKeyFunc(ctx, hostID, encryptedBase64Key) + return s.SetOrUpdateHostDiskEncryptionKeyFunc(ctx, hostID, encryptedBase64Key, clientError, decryptable) } func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { @@ -4017,11 +4037,11 @@ func (s *DataStore) WSTEPAssociateCertHash(ctx context.Context, deviceUUID strin return s.WSTEPAssociateCertHashFunc(ctx, deviceUUID, hash) } -func (s *DataStore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { +func (s *DataStore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceHWID string) (*fleet.MDMWindowsEnrolledDevice, error) { s.mu.Lock() s.MDMWindowsGetEnrolledDeviceFuncInvoked = true s.mu.Unlock() - return s.MDMWindowsGetEnrolledDeviceFunc(ctx, mdmDeviceID) + return s.MDMWindowsGetEnrolledDeviceFunc(ctx, mdmDeviceHWID) } func (s *DataStore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device *fleet.MDMWindowsEnrolledDevice) error { @@ -4031,11 +4051,39 @@ func (s *DataStore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device * return s.MDMWindowsInsertEnrolledDeviceFunc(ctx, device) } -func (s *DataStore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceID string) error { +func (s *DataStore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceHWID string) error { s.mu.Lock() s.MDMWindowsDeleteEnrolledDeviceFuncInvoked = true s.mu.Unlock() - return s.MDMWindowsDeleteEnrolledDeviceFunc(ctx, mdmDeviceID) + return s.MDMWindowsDeleteEnrolledDeviceFunc(ctx, mdmDeviceHWID) +} + +func (s *DataStore) MDMWindowsGetEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + s.mu.Lock() + s.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked = true + s.mu.Unlock() + return s.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc(ctx, mdmDeviceID) +} + +func (s *DataStore) MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error { + s.mu.Lock() + s.MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked = true + s.mu.Unlock() + return s.MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc(ctx, mdmDeviceID) +} + +func (s *DataStore) GetMDMWindowsBitLockerSummary(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + s.mu.Lock() + s.GetMDMWindowsBitLockerSummaryFuncInvoked = true + s.mu.Unlock() + return s.GetMDMWindowsBitLockerSummaryFunc(ctx, teamID) +} + +func (s *DataStore) GetMDMWindowsBitLockerStatus(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) { + s.mu.Lock() + s.GetMDMWindowsBitLockerStatusFuncInvoked = true + s.mu.Unlock() + return s.GetMDMWindowsBitLockerStatusFunc(ctx, host) } func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 0b279e5b8ae5..a3008831c12c 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" + "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" @@ -78,6 +79,31 @@ func (r *appConfigResponse) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON implements the json.Marshaler interface to make sure we serialize +// both AppConfig and responseFields properly: +// +// - If this function is not defined, AppConfig.MarshalJSON gets promoted and +// will be called instead. +// - If we try to unmarshal everything in one go, AppConfig.MarshalJSON doesn't get +// called. +func (r appConfigResponse) MarshalJSON() ([]byte, error) { + // Marshal only the response fields + responseData, err := json.Marshal(r.appConfigResponseFields) + if err != nil { + return nil, err + } + + // Marshal the base AppConfig + appConfigData, err := json.Marshal(r.AppConfig) + if err != nil { + return nil, err + } + + // we need to marshal and combine both groups separately because + // AppConfig has a custom marshaler. + return rawjson.CombineRoots(responseData, appConfigData) +} + func (r appConfigResponse) error() error { return r.Err } func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { @@ -340,6 +366,15 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } + // TODO: move this logic to the AppConfig unmarshaller? we need to do + // this because we unmarshal twice into appConfig: + // + // 1. To get the JSON value from the database + // 2. To update fields with the incoming values + if newAppConfig.MDM.EnableDiskEncryption.Valid { + appConfig.MDM.EnableDiskEncryption = newAppConfig.MDM.EnableDiskEncryption + } + fleet.ValidateEnabledVulnerabilitiesIntegrations(appConfig.WebhookSettings.VulnerabilitiesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledFailingPoliciesIntegrations(appConfig.WebhookSettings.FailingPoliciesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledHostStatusIntegrations(appConfig.WebhookSettings.HostStatusWebhook, invalid) @@ -502,22 +537,24 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } - if oldAppConfig.MDM.MacOSSettings.EnableDiskEncryption != appConfig.MDM.MacOSSettings.EnableDiskEncryption { - var act fleet.ActivityDetails - if appConfig.MDM.MacOSSettings.EnableDiskEncryption { - act = fleet.ActivityTypeEnabledMacosDiskEncryption{} - if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil { - return nil, ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow") + if appConfig.MDM.EnableDiskEncryption.Valid && oldAppConfig.MDM.EnableDiskEncryption.Value != appConfig.MDM.EnableDiskEncryption.Value { + if oldAppConfig.MDM.EnabledAndConfigured { + var act fleet.ActivityDetails + if appConfig.MDM.EnableDiskEncryption.Value { + act = fleet.ActivityTypeEnabledMacosDiskEncryption{} + if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil { + return nil, ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow") + } + } else { + act = fleet.ActivityTypeDisabledMacosDiskEncryption{} + if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil { + return nil, ctxerr.Wrap(ctx, err, "disable no-team filevault and escrow") + } } - } else { - act = fleet.ActivityTypeDisabledMacosDiskEncryption{} - if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil { - return nil, ctxerr.Wrap(ctx, err, "disable no-team filevault and escrow") + if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos disk encryption") } } - if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos disk encryption") - } } mdmEnableEndUserAuthChanged := oldAppConfig.MDM.MacOSSetup.EnableEndUserAuthentication != appConfig.MDM.MacOSSetup.EnableEndUserAuthentication @@ -565,7 +602,7 @@ func (svc *Service) validateMDM( mdm *fleet.MDM, invalid *fleet.InvalidArgumentError, ) { - if mdm.MacOSSettings.EnableDiskEncryption && !license.IsPremium() { + if mdm.EnableDiskEncryption.Value && !license.IsPremium() { invalid.Append("macos_settings.enable_disk_encryption", ErrMissingLicense.Error()) } if oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value && !license.IsPremium() { @@ -586,11 +623,6 @@ func (svc *Service) validateMDM( `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } - if mdm.MacOSSettings.EnableDiskEncryption { - invalid.Append("macos_settings.enable_disk_encryption", - `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) - } - if oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value { invalid.Append("macos_setup.macos_setup_assistant", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) @@ -683,6 +715,14 @@ func (svc *Service) validateMDM( return } } + + // if either macOS or Windows MDM is enabled, this setting can be set. + if !mdm.AtLeastOnePlatformEnabledAndConfigured() { + if mdm.EnableDiskEncryption.Valid && mdm.EnableDiskEncryption.Value != oldMdm.EnableDiskEncryption.Value { + invalid.Append("mdm.enable_disk_encryption", + `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) + } + } } func validateSSOProviderSettings(incoming, existing fleet.SSOProviderSettings, invalid *fleet.InvalidArgumentError) { diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 38598287e8be..6efc8a602d99 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -810,8 +810,9 @@ func TestMDMAppleConfig(t *testing.T) { name: "nochange", licenseTier: "free", expectedMDM: fleet.MDM{ - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "newDefaultTeamNoLicense", @@ -835,9 +836,10 @@ func TestMDMAppleConfig(t *testing.T) { findTeam: true, newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ - AppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + AppleBMDefaultTeam: "foobar", + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "foundEdit", @@ -846,9 +848,10 @@ func TestMDMAppleConfig(t *testing.T) { oldMDM: fleet.MDM{AppleBMDefaultTeam: "bar"}, newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ - AppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + AppleBMDefaultTeam: "foobar", + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "ssoFree", @@ -866,6 +869,7 @@ func TestMDMAppleConfig(t *testing.T) { EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "ssoAllFields", @@ -884,8 +888,9 @@ func TestMDMAppleConfig(t *testing.T) { MetadataURL: "http://isser.metadata.com", IDPName: "onelogin", }}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "ssoShortEntityID", diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 5417b8f09456..53270d295046 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -18,6 +18,7 @@ import ( "github.com/VividCortex/mysqlerr" "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -609,7 +610,6 @@ func getMdmAppleFileVaultSummaryEndpoint(ctx context.Context, request interface{ }, nil } -// QUESTION: workflow for developing new APIs? whats your setup quickly test code working? func (svc *Service) GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) { if err := svc.authz.Authorize(ctx, fleet.MDMAppleConfigProfile{TeamID: teamID}, fleet.ActionRead); err != nil { return nil, ctxerr.Wrap(ctx, err) @@ -1716,8 +1716,8 @@ func (svc *Service) updateAppConfigMDMAppleSettings(ctx context.Context, payload var didUpdate, didUpdateMacOSDiskEncryption bool if payload.EnableDiskEncryption != nil { - if ac.MDM.MacOSSettings.EnableDiskEncryption != *payload.EnableDiskEncryption { - ac.MDM.MacOSSettings.EnableDiskEncryption = *payload.EnableDiskEncryption + if ac.MDM.EnableDiskEncryption.Value != *payload.EnableDiskEncryption { + ac.MDM.EnableDiskEncryption = optjson.SetBool(*payload.EnableDiskEncryption) didUpdate = true didUpdateMacOSDiskEncryption = true } @@ -1729,7 +1729,7 @@ func (svc *Service) updateAppConfigMDMAppleSettings(ctx context.Context, payload } if didUpdateMacOSDiskEncryption { var act fleet.ActivityDetails - if ac.MDM.MacOSSettings.EnableDiskEncryption { + if ac.MDM.EnableDiskEncryption.Value { act = fleet.ActivityTypeEnabledMacosDiskEncryption{} if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil { return ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow") diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 49f941ce3c46..8815b63a2846 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -697,15 +697,15 @@ func TestHostDetailsMDMProfiles(t *testing.T) { } ds.HostFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { if hostID == uint(42) { - return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337"}, nil + return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337", Platform: "darwin"}, nil } - return &fleet.Host{ID: hostID, UUID: "WR0N6-UU1D"}, nil + return &fleet.Host{ID: hostID, UUID: "WR0N6-UU1D", Platform: "darwin"}, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { if identifier == "h0571d3n71f13r" { - return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337"}, nil + return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337", Platform: "darwin"}, nil } - return &fleet.Host{ID: uint(21), UUID: "WR0N6-UU1D"}, nil + return &fleet.Host{ID: uint(21), UUID: "WR0N6-UU1D", Platform: "darwin"}, nil } ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { return nil diff --git a/server/service/handler.go b/server/service/handler.go index 91c7219e1b12..64734b146499 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -449,7 +449,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update - // `service.mdmAppleConfigurationRequiredEndpoints` when you add an + // `service.mdmConfigurationRequiredEndpoints` when you add an // endpoint that's behind the mdmConfiguredMiddleware, this applies // both to this set of endpoints and to any public/token-authenticated // endpoints using `neMDM` below in this file. @@ -485,7 +485,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // host-specific mdm routes mdmAppleMW.PATCH("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/unenroll", mdmAppleCommandRemoveEnrollmentProfileEndpoint, mdmAppleCommandRemoveEnrollmentProfileRequest{}) - mdmAppleMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/lock", deviceLockEndpoint, deviceLockRequest{}) mdmAppleMW.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/wipe", deviceWipeEndpoint, deviceWipeRequest{}) mdmAppleMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/profiles", getHostProfilesEndpoint, getHostProfilesRequest{}) @@ -501,6 +501,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileEndpoint, preassignMDMAppleProfileRequest{}) mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentEndpoint, matchMDMApplePreassignmentRequest{}) + mdmAppleOrWinMW := ue.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleOrWindowsMDM()) + mdmAppleOrWinMW.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) + mdmAppleOrWinMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + // the following set of mdm endpoints must always be accessible (even // if MDM is not configured) as it bootstraps the setup of MDM // (generates CSR request for APNs, plus the SCEP and ABM keypairs). @@ -588,6 +592,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC oe.POST("/api/fleet/orbit/scripts/request", getOrbitScriptEndpoint, orbitGetScriptRequest{}) oe.POST("/api/fleet/orbit/scripts/result", postOrbitScriptResultEndpoint, orbitPostScriptResultRequest{}) + oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) + oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{}) + // unauthenticated endpoints - most of those are either login-related, // invite-related or host-enrolling. So they typically do some kind of // one-time authentication by verifying that a valid secret token is provided @@ -598,7 +605,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // These endpoint are token authenticated. // NOTE: remember to update - // `service.mdmAppleConfigurationRequiredEndpoints` when you add an + // `service.mdmConfigurationRequiredEndpoints` when you add an // endpoint that's behind the mdmConfiguredMiddleware, this applies // both to this set of endpoints and to any user authenticated // endpoints using `mdmAppleMW.*` above in this file. diff --git a/server/service/hosts.go b/server/service/hosts.go index 840807bde9cf..db568e87b665 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "crypto/tls" "encoding/csv" "encoding/json" "errors" @@ -19,7 +20,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/worker" "github.com/gocarina/gocsv" @@ -918,21 +919,34 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f var profiles []fleet.HostMDMAppleProfile if ac.MDM.EnabledAndConfigured { - profs, err := svc.ds.GetHostMDMProfiles(ctx, host.UUID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get host mdm profiles") - } + host.MDM.OSSettings = &fleet.HostMDMOSSettings{} + switch host.Platform { + case "windows": + if license.IsPremium(ctx) { + bls, err := svc.ds.GetMDMWindowsBitLockerStatus(ctx, host) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host mdm bitlocker status") + } + host.MDM.OSSettings.DiskEncryption.Status = bls + } + case "darwin": + profs, err := svc.ds.GetHostMDMProfiles(ctx, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host mdm profiles") + } - // determine disk encryption and action required here based on profiles and - // raw decryptable key status. - host.MDM.DetermineDiskEncryptionStatus(profs, mobileconfig.FleetFileVaultPayloadIdentifier) + // determine disk encryption and action required here based on profiles and + // raw decryptable key status. + host.MDM.DetermineMacOSDiskEncryptionStatus(profs, mobileconfig.FleetFileVaultPayloadIdentifier) + host.MDM.OSSettings.DiskEncryption.Status = host.MDM.MacOSSettings.DiskEncryption - for _, p := range profs { - if p.Identifier == mobileconfig.FleetFileVaultPayloadIdentifier { - p.Status = host.MDM.ProfileStatusFromDiskEncryptionState(p.Status) + for _, p := range profs { + if p.Identifier == mobileconfig.FleetFileVaultPayloadIdentifier { + p.Status = host.MDM.ProfileStatusFromDiskEncryptionState(p.Status) + } + p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message() + profiles = append(profiles, p) } - p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message() - profiles = append(profiles, p) } } host.MDM.Profiles = &profiles @@ -1563,25 +1577,48 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host return nil, err } - key, err := svc.ds.GetHostDiskEncryptionKey(ctx, id) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") - } + // The middleware checks that either Apple or Windows MDM are configured and + // enabled, but here we must check if the specific one is enabled for that + // particular host's platform. + var decryptCert *tls.Certificate + switch host.FleetPlatform() { + case "windows": + if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { + return nil, err + } - if key.Decryptable == nil || !*key.Decryptable { - return nil, ctxerr.Wrap(ctx, newNotFoundError(), "getting host encryption key") + // use Microsoft's WSTEP certificate for decrypting + cert, _, _, err := svc.config.MDM.MicrosoftWSTEP() + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key") + } + decryptCert = cert + + default: + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + return nil, err + } + + // use Apple's SCEP certificate for decrypting + cert, _, _, err := svc.config.MDM.AppleSCEP() + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting Apple SCEP certificate to decrypt key") + } + decryptCert = cert } - cert, _, _, err := svc.config.MDM.AppleSCEP() + key, err := svc.ds.GetHostDiskEncryptionKey(ctx, id) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") } + if key.Decryptable == nil || !*key.Decryptable { + return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not decryptable") + } - decryptedKey, err := apple_mdm.DecryptBase64CMS(key.Base64Encrypted, cert.Leaf, cert.PrivateKey) + decryptedKey, err := mdm.DecryptBase64CMS(key.Base64Encrypted, decryptCert.Leaf, decryptCert.PrivateKey) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key") } - key.DecryptedValue = string(decryptedKey) err = svc.ds.NewActivity( diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index e62fe936c648..1b77fa3f70e2 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -14,6 +14,7 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -83,7 +84,7 @@ func TestHostDetails(t *testing.T) { require.Nil(t, hostDetail.MDM.MacOSSettings) } -func TestHostDetailsMDMDiskEncryption(t *testing.T) { +func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { ds := new(mock.Store) svc := &Service{ds: ds} @@ -308,7 +309,7 @@ func TestHostDetailsMDMDiskEncryption(t *testing.T) { } require.NoError(t, mdmData.Scan([]byte(fmt.Sprintf(`{"raw_decryptable": %s}`, rawDecrypt)))) - host := &fleet.Host{ID: 3, MDM: mdmData, UUID: "abc"} + host := &fleet.Host{ID: 3, MDM: mdmData, UUID: "abc", Platform: "darwin"} opts := fleet.HostDetailOptions{ IncludeCVEScores: false, IncludePolicies: false, @@ -322,12 +323,16 @@ func TestHostDetailsMDMDiskEncryption(t *testing.T) { } hostDetail, err := svc.getHostDetails(test.UserContext(context.Background(), test.UserAdmin), host, opts) require.NoError(t, err) + require.NotNil(t, hostDetail.MDM.MacOSSettings) if c.wantState == "" { require.Nil(t, hostDetail.MDM.MacOSSettings.DiskEncryption) + require.Nil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) } else { require.NotNil(t, hostDetail.MDM.MacOSSettings.DiskEncryption) require.Equal(t, c.wantState, *hostDetail.MDM.MacOSSettings.DiskEncryption) + require.NotNil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + require.Equal(t, c.wantState, *hostDetail.MDM.OSSettings.DiskEncryption.Status) } if c.wantAction == "" { require.Nil(t, hostDetail.MDM.MacOSSettings.ActionRequired) @@ -346,6 +351,100 @@ func TestHostDetailsMDMDiskEncryption(t *testing.T) { } } +func TestHostDetailsOSSettings(t *testing.T) { + ds := new(mock.Store) + svc := &Service{ds: ds} + + ctx := context.Background() + + ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Pack, error) { + return nil, nil + } + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { + return nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) { + return nil, nil + } + + type testCase struct { + name string + host *fleet.Host + licenseTier string + wantStatus fleet.DiskEncryptionStatus + } + cases := []testCase{ + {"windows", &fleet.Host{ID: 42, Platform: "windows"}, fleet.TierPremium, fleet.DiskEncryptionEnforcing}, + {"darwin", &fleet.Host{ID: 42, Platform: "darwin"}, fleet.TierPremium, ""}, + {"ubuntu", &fleet.Host{ID: 42, Platform: "ubuntu"}, fleet.TierPremium, ""}, + {"not premium", &fleet.Host{ID: 42, Platform: "windows"}, fleet.TierFree, ""}, + } + + setupDS := func(c testCase) { + ds.AppConfigFuncInvoked = false + ds.GetMDMWindowsBitLockerStatusFuncInvoked = false + ds.GetHostMDMProfilesFuncInvoked = false + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) { + if c.wantStatus == "" { + return nil, nil + } + return &c.wantStatus, nil + } + ds.GetHostMDMProfilesFunc = func(ctx context.Context, uuid string) ([]fleet.HostMDMAppleProfile, error) { + return nil, nil + } + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setupDS(c) + + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: c.licenseTier}) + + hostDetail, err := svc.getHostDetails(test.UserContext(ctx, test.UserAdmin), c.host, fleet.HostDetailOptions{ + IncludeCVEScores: false, + IncludePolicies: false, + }) + require.NoError(t, err) + require.NotNil(t, hostDetail) + require.True(t, ds.AppConfigFuncInvoked) + + switch c.host.Platform { + case "windows": + require.False(t, ds.GetHostMDMProfilesFuncInvoked) + if c.wantStatus != "" { + require.True(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + require.NotNil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + require.Equal(t, c.wantStatus, *hostDetail.MDM.OSSettings.DiskEncryption.Status) + } else { + require.False(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + require.Nil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + } + case "darwin": + require.True(t, ds.GetHostMDMProfilesFuncInvoked) + require.False(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + require.Nil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + default: + require.False(t, ds.GetHostMDMProfilesFuncInvoked) + require.False(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + } + }) + } +} + func TestHostAuth(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil) @@ -902,9 +1001,18 @@ func TestHostEncryptionKey(t *testing.T) { require.NoError(t, err) base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey) + wstep, _, _, err := fleetCfg.MDM.MicrosoftWSTEP() + require.NoError(t, err) + winEncryptedKey, err := pkcs7.Encrypt([]byte(recoveryKey), []*x509.Certificate{wstep.Leaf}) + require.NoError(t, err) + winBase64EncryptedKey := base64.StdEncoding.EncodeToString(winEncryptedKey) + for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { ds := new(mock.Store) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { @@ -951,7 +1059,10 @@ func TestHostEncryptionKey(t *testing.T) { t.Run("test error cases", func(t *testing.T) { ds := new(mock.Store) - svc, ctx := newTestService(t, ds, nil, nil) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) ctx = test.UserContext(ctx, test.UserAdmin) hostErr := errors.New("host error") @@ -981,6 +1092,56 @@ func TestHostEncryptionKey(t *testing.T) { _, err = svc.HostEncryptionKey(ctx, 1) require.Error(t, err) }) + + t.Run("host platform mdm enabled", func(t *testing.T) { + cases := []struct { + hostPlatform string + macMDMEnabled bool + winMDMEnabled bool + shouldFail bool + }{ + {"windows", true, false, true}, + {"windows", false, true, false}, + {"windows", true, true, false}, + {"darwin", true, false, false}, + {"darwin", false, true, true}, + {"darwin", true, true, false}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%s: mac mdm: %t; win mdm: %t", c.hostPlatform, c.macMDMEnabled, c.winMDMEnabled), func(t *testing.T) { + ds := new(mock.Store) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: c.macMDMEnabled, WindowsEnabledAndConfigured: c.winMDMEnabled}}, nil + } + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{Platform: c.hostPlatform}, nil + } + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + key := base64EncryptedKey + if c.hostPlatform == "windows" { + key = winBase64EncryptedKey + } + return &fleet.HostDiskEncryptionKey{ + Base64Encrypted: key, + Decryptable: ptr.Bool(true), + }, nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + + svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) + ctx = test.UserContext(ctx, test.UserAdmin) + _, err := svc.HostEncryptionKey(ctx, 1) + if c.shouldFail { + require.Error(t, err) + require.ErrorContains(t, err, fleet.ErrMDMNotConfigured.Error()) + } else { + require.NoError(t, err) + } + }) + } + }) } func TestHostMDMProfileDetail(t *testing.T) { @@ -1005,7 +1166,8 @@ func TestHostMDMProfileDetail(t *testing.T) { ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ - ID: 1, + ID: 1, + Platform: "darwin", }, nil } ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ca2b669ac1e6..1a9d2bad7c70 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -4974,11 +4974,18 @@ func (s *integrationTestSuite) TestAppConfig() { // set the macos disk encryption field, fails due to license res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, "missing or invalid license") + // legacy config + res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "macos_settings": { "enable_disk_encryption": true } } + }`), http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + assert.Contains(t, errMsg, "missing or invalid license") + // try to set the apple bm default team, which is premium only s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "apple_bm_default_team": "xyz" } @@ -6429,6 +6436,16 @@ func (s *integrationTestSuite) TestGetHostDiskEncryption() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostLin.ID), nil, http.StatusOK, &getHostResp) require.Equal(t, hostLin.ID, getHostResp.Host.ID) require.Nil(t, getHostResp.Host.DiskEncryptionEnabled) + + // the orbit endpoint to set the disk encryption key always fails in this + // suite because MDM is not configured. + orbitHost := createOrbitEnrolledHost(t, "windows", "diskenc", s.ds) + res := s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *orbitHost.OrbitNodeKey, + EncryptionKey: []byte("testkey"), + }, http.StatusBadRequest) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error()) } func (s *integrationTestSuite) TestOSVersions() { @@ -6481,14 +6498,14 @@ func (s *integrationTestSuite) TestPingEndpoints() { s.DoRawNoAuth("HEAD", "/api/fleet/device/ping", nil, http.StatusOK) } -func (s *integrationTestSuite) TestAppleMDMNotConfigured() { +func (s *integrationTestSuite) TestMDMNotConfiguredEndpoints() { t := s.T() // create a host with device token to test device authenticated routes tkn := "D3V1C370K3N" createHostAndDeviceToken(t, s.ds, tkn) - for _, route := range mdmAppleConfigurationRequiredEndpoints() { + for _, route := range mdmConfigurationRequiredEndpoints() { which := fmt.Sprintf("%s %s", route.method, route.path) var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured if route.premiumOnly && route.deviceAuthenticated { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 8af523353eab..d496f142bb8e 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -239,7 +239,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { require.NoError(t, err) require.Contains(t, string(*team.Config.AgentOptions), `"foo": "bar"`) // unchanged require.Empty(t, team.Config.MDM.MacOSSettings.CustomSettings) // unchanged - require.False(t, team.Config.MDM.MacOSSettings.EnableDiskEncryption) // unchanged + require.False(t, team.Config.MDM.EnableDiskEncryption) // unchanged // apply without agent options specified teamSpecs = map[string]any{ @@ -764,7 +764,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { // modify team's disk encryption, impossible without mdm enabled res := s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{ MDM: &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: true}, + EnableDiskEncryption: optjson.SetBool(true), }, }, http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) @@ -2539,14 +2539,14 @@ func (s *integrationEnterpriseTestSuite) TestListHosts() { require.Nil(t, summaryResp.LowDiskSpaceCount) } -func (s *integrationEnterpriseTestSuite) TestAppleMDMNotConfigured() { +func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() { t := s.T() // create a host with device token to test device authenticated routes tkn := "D3V1C370K3N" createHostAndDeviceToken(t, s.ds, tkn) - for _, route := range mdmAppleConfigurationRequiredEndpoints() { + for _, route := range mdmConfigurationRequiredEndpoints() { var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured path := route.path if route.deviceAuthenticated { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ddf83f81a248..618d6c2dcdd8 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -32,6 +32,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/fleet" + servermdm "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" @@ -238,7 +239,7 @@ func (s *integrationMDMTestSuite) TearDownTest() { // ensure windows mdm is always enabled for the next test appCfg.MDM.WindowsEnabledAndConfigured = true // ensure global disk encryption is disabled on exit - appCfg.MDM.MacOSSettings.EnableDiskEncryption = false + appCfg.MDM.EnableDiskEncryption = optjson.SetBool(false) err := s.ds.SaveAppConfig(ctx, &appCfg.AppConfig) require.NoError(t, err) @@ -1059,7 +1060,7 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) // filevault is enabled by default - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // setup assistant settings are copyied from "no team" teamAsst, err := s.ds.GetMDMAppleSetupAssistant(ctx, &tm1.ID) require.NoError(t, err) @@ -1146,7 +1147,7 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { // simulate having its profiles installed mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.MacOSSettingsVerifying, mdmHost2.UUID) + _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.OSSettingsVerifying, mdmHost2.UUID) return err }) @@ -1297,7 +1298,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in puppetRun(host2) @@ -1318,7 +1319,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.Equal(t, prof4, []byte(profs[3].Mobileconfig)) - require.True(t, tm2.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm2.Config.MDM.EnableDiskEncryption) // host3 checks in puppetRun(host3) @@ -1345,7 +1346,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in, still belongs to the same team puppetRun(host2) @@ -1362,7 +1363,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.Equal(t, prof4, []byte(profs[3].Mobileconfig)) require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // the puppet manifest is changed, and prof3 is removed // node default { @@ -1423,7 +1424,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Len(t, profs, 2) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // same for host2 puppetRun(host2) @@ -1436,7 +1437,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof4, []byte(profs[2].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // The puppet manifest is drastically updated, this time to use exclusions on host3: // @@ -1515,7 +1516,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in puppetRun(host2) @@ -1544,7 +1545,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Len(t, profs, 2) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) - require.True(t, tm3.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm3.Config.MDM.EnableDiskEncryption) } func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestMDMClient) { @@ -2174,6 +2175,261 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { require.Equal(t, "", hostResp.Host.MDM.Name) } +func (s *integrationMDMTestSuite) TestMDMDiskEncryptionSettingBackwardsCompat() { + t := s.T() + + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + + // new config takes precedence over old config + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false, "macos_settings": {"enable_disk_encryption": true} } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + + s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // if new config is not present, old config is applied + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "macos_settings": {"enable_disk_encryption": true} } + }`), http.StatusOK, &acResp) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) + s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) + + // new config takes precedence over old config again + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false, "macos_settings": {"enable_disk_encryption": true} } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // unrelated change doesn't affect the disk encryption setting + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "macos_settings": {"custom_settings": ["test.mobileconfig"]} } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + + // Same tests, but for teams + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1_" + t.Name(), + Description: "desc team1_" + t.Name(), + }) + require.NoError(t, err) + + checkTeamDiskEncryption := func(wantSetting bool) { + var teamResp getTeamResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Equal(t, wantSetting, teamResp.Team.Config.MDM.EnableDiskEncryption) + } + + // after creation, disk encryption is off + checkTeamDiskEncryption(false) + + // new config takes precedence over old config + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(false), + MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(false) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // if new config is not present, old config is applied + teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(true) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, true) + + // new config takes precedence over old config again + teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(false), + MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(false) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // unrelated change doesn't affect the disk encryption setting + teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(false), + MacOSSettings: map[string]interface{}{"custom_settings": []interface{}{"A", "B"}}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(false) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false) +} + +func (s *integrationMDMTestSuite) TestDiskEncryptionSharedSetting() { + t := s.T() + + // create a team + teamName := t.Name() + team := &fleet.Team{ + Name: teamName, + Description: "desc " + teamName, + } + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + + setMDMEnabled := func(macMDM, windowsMDM bool) { + appConf, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + appConf.MDM.WindowsEnabledAndConfigured = windowsMDM + appConf.MDM.EnabledAndConfigured = macMDM + err = s.ds.SaveAppConfig(context.Background(), appConf) + require.NoError(s.T(), err) + } + + // before doing any modifications, grab the current values and make + // sure they're set to the same ones on cleanup to not interfere with + // other tests. + origAppConf, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + t.Cleanup(func() { + err := s.ds.SaveAppConfig(context.Background(), origAppConf) + require.NoError(s.T(), err) + }) + + checkConfigSetErrors := func() { + // try to set app config + res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": true } + }`), http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") + + // try to create a new team using specs + teamSpecs := map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName + uuid.NewString(), + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") + + // try to edit the existing team using specs + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") + } + + checkConfigSetSucceeds := func() { + res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": true } + }`), http.StatusOK) + errMsg := extractServerErrorText(res.Body) + require.Empty(t, errMsg) + + // try to create a new team using specs + teamSpecs := map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName + uuid.NewString(), + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + errMsg = extractServerErrorText(res.Body) + require.Empty(t, errMsg) + + // edit the existing team using specs + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + errMsg = extractServerErrorText(res.Body) + require.Empty(t, errMsg) + + // always try to set the value to `false` so we start fresh + s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false } + }`), http.StatusOK) + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "enable_disk_encryption": false, + }, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + } + + // 1. disable both windows and mac mdm + // 2. turn off windows feature flag + // we should get an error + setMDMEnabled(false, false) + t.Setenv("FLEET_DEV_MDM_ENABLED", "0") + checkConfigSetErrors() + + // turn on windows feature flag + // we should get an error + t.Setenv("FLEET_DEV_MDM_ENABLED", "1") + checkConfigSetErrors() + + // enable windows mdm, no errors + setMDMEnabled(false, true) + checkConfigSetSucceeds() + + // enable mac mdm, no errors + setMDMEnabled(true, true) + checkConfigSetSucceeds() + + // only macos mdm enabled, no errors + setMDMEnabled(true, false) + checkConfigSetSucceeds() +} + func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { t := s.T() ctx := context.Background() @@ -2196,9 +2452,9 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) fileVaultProf := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) hostCmdUUID := uuid.New().String() err = s.ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ @@ -2246,7 +2502,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { require.NoError(t, err) base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey) - err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64EncryptedKey) + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64EncryptedKey, "", nil) require.NoError(t, err) // get that host - it has an encryption key with unknown decryptability, so @@ -2321,7 +2577,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: "team1_" + t.Name(), MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + EnableDiskEncryption: optjson.SetBool(true), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) @@ -2420,6 +2676,52 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusForbidden, &resp) } +func (s *integrationMDMTestSuite) TestWindowsMDMGetEncryptionKey() { + t := s.T() + ctx := context.Background() + + // create a host and enroll it in Fleet + host := createOrbitEnrolledHost(t, "windows", "h1", s.ds) + err := s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet) + require.NoError(t, err) + + // request encryption key with no auth token + res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusUnauthorized) + res.Body.Close() + + // no encryption key + resp := getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp) + + // invalid host id + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID+999), nil, http.StatusNotFound, &resp) + + // add an encryption key for the host + cert, _, _, err := s.fleetCfg.MDM.MicrosoftWSTEP() + require.NoError(t, err) + recoveryKey := "AAA-BBB-CCC" + encryptedKey, err := microsoft_mdm.Encrypt(recoveryKey, cert.Leaf) + require.NoError(t, err) + + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, encryptedKey, "", ptr.Bool(true)) + require.NoError(t, err) + + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusOK, &resp) + require.Equal(t, host.ID, resp.HostID) + require.Equal(t, recoveryKey, resp.EncryptionKey.DecryptedValue) + s.lastActivityOfTypeMatches(fleet.ActivityTypeReadHostDiskEncryptionKey{}.ActivityName(), + fmt.Sprintf(`{"host_display_name": "%s", "host_id": %d}`, host.DisplayName(), host.ID), 0) + + // update the key to blank with a client error + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "", "failed", nil) + require.NoError(t, err) + + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp) +} + func (s *integrationMDMTestSuite) TestMDMAppleListConfigProfiles() { t := s.T() ctx := context.Background() @@ -2663,9 +2965,9 @@ func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() { // make fleet add a FileVault profile acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) // try to delete the profile @@ -2693,7 +2995,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleProfiles() { // field, should not remove them acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": {"enable_disk_encryption": true} } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.MacOSSettings.CustomSettings) @@ -2719,9 +3021,9 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // set the macos disk encryption field acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) enabledDiskActID := s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0) @@ -2731,7 +3033,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // check that they are returned by a GET /config acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) // patch without specifying the macos disk encryption and an unrelated field, // should not alter it @@ -2739,7 +3041,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": {"custom_settings": ["a"]} } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"a"}, acResp.MDM.MacOSSettings.CustomSettings) s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, enabledDiskActID) @@ -2747,9 +3049,9 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // patch with false, would reset it but this is a dry-run acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": false } } + "mdm": { "enable_disk_encryption": false } }`), http.StatusOK, &acResp, "dry_run", "true") - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"a"}, acResp.MDM.MacOSSettings.CustomSettings) s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, enabledDiskActID) @@ -2757,9 +3059,9 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // patch with false, resets it acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": false, "custom_settings": ["b"] } } + "mdm": { "enable_disk_encryption": false, "macos_settings": { "custom_settings": ["b"] } } }`), http.StatusOK, &acResp) - assert.False(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings) s.lastActivityMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0) @@ -2778,7 +3080,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings) // call update endpoint with no changes @@ -2792,7 +3094,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings) } @@ -2856,7 +3158,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleDiskEncryptionAggregate() { }) require.NoError(t, err) oneMinuteAfterThreshold := time.Now().Add(+1 * time.Minute) - err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "test-key") + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "test-key", "", nil) require.NoError(t, err) err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, decryptable, oneMinuteAfterThreshold) require.NoError(t, err) @@ -3043,7 +3345,7 @@ func (s *integrationMDMTestSuite) TestApplyTeamsMDMAppleProfiles() { teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": false}, + EnableDiskEncryption: optjson.SetBool(false), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) @@ -3098,7 +3400,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + EnableDiskEncryption: optjson.SetBool(true), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) @@ -3111,7 +3413,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { // retrieving the team returns the disk encryption setting var teamResp getTeamResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) // apply with invalid disk encryption value should fail teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ @@ -3142,7 +3444,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) require.Equal(t, []string{"a"}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings) s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, lastDiskActID) @@ -3151,13 +3453,13 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": false}, + EnableDiskEncryption: optjson.SetBool(false), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true") teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, lastDiskActID) @@ -3171,7 +3473,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.False(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.False(t, teamResp.Team.Config.MDM.EnableDiskEncryption) s.lastActivityOfTypeMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0) @@ -3182,10 +3484,11 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { var modResp teamResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{ MDM: &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: true}, + EnableDiskEncryption: optjson.SetBool(true), + MacOSSettings: &fleet.MacOSSettings{}, }, }, http.StatusOK, &modResp) - require.True(t, modResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, modResp.Team.Config.MDM.EnableDiskEncryption) s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0) @@ -3197,10 +3500,10 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{ Description: ptr.String("foobar"), MDM: &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: false}, + EnableDiskEncryption: optjson.SetBool(false), }, }, http.StatusOK, &modResp) - require.False(t, modResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.False(t, modResp.Team.Config.MDM.EnableDiskEncryption) require.Equal(t, "foobar", modResp.Team.Description) s.lastActivityOfTypeMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0) @@ -3219,7 +3522,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) // use the MDM settings endpoint with no changes s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings", @@ -3232,7 +3535,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) // use the MDM settings endpoint with an unknown team id s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings", @@ -3449,7 +3752,7 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesStatus() { // profile deployment is asynchronous, so we simulate it here by // updating any "pending" (not NULL) profiles to "verifying" mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.MacOSSettingsVerifying, fleet.MacOSSettingsPending) + _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending) return err }) } @@ -5381,9 +5684,9 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() { // Attempt to edit global MDM settings, should allow. acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) // Attempt to setup Apple MDM, will fail but the important thing is that it // fails with 422 (cannot enable end user auth because no IdP is configured) @@ -5407,9 +5710,9 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() { teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: t1.Name, MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(true), MacOSSettings: map[string]interface{}{ - "enable_disk_encryption": true, - "custom_settings": []interface{}{"foo", "bar"}, + "custom_settings": []interface{}{"foo", "bar"}, }, }, }}} @@ -5434,9 +5737,9 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() { teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: t1.Name, MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(true), MacOSSettings: map[string]interface{}{ - "enable_disk_encryption": true, - "custom_settings": []interface{}{"foo", "bar"}, + "custom_settings": []interface{}{"foo", "bar"}, }, }, }}} @@ -5590,7 +5893,7 @@ func (s *integrationMDMTestSuite) TestSSO() { // field, should not remove them acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": {"enable_disk_encryption": true} } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) assert.Equal(t, wantSettings, acResp.MDM.EndUserAuthentication.SSOProviderSettings) @@ -6703,8 +7006,28 @@ func (s *integrationMDMTestSuite) TestValidSyncMLRequestNoAuth() { // Target Endpoint URL for the management endpoint targetEndpointURL := microsoft_mdm.MDE2ManagementPath + // Target DeviceID to use + deviceID := "DB257C3A08778F4FB61E2749066C1F27" + + // Inserting new device + enrolledDevice := &fleet.MDMWindowsEnrolledDevice{ + MDMDeviceID: deviceID, + MDMHardwareID: uuid.New().String() + uuid.New().String(), + MDMDeviceState: uuid.New().String(), + MDMDeviceType: "CIMClient_Windows", + MDMDeviceName: "DESKTOP-1C3ARC1", + MDMEnrollType: "ProgrammaticEnrollment", + MDMEnrollUserID: "upn@domain.com", + MDMEnrollProtoVersion: "5.0", + MDMEnrollClientVersion: "10.0.19045.2965", + MDMNotInOOBE: false, + } + + err := s.ds.MDMWindowsInsertEnrolledDevice(context.Background(), enrolledDevice) + require.NoError(t, err) + // Preparing the SyncML request - requestBytes, err := s.newSyncMLSessionMsg(targetEndpointURL) + requestBytes, err := s.newSyncMLSessionMsg(deviceID, targetEndpointURL) require.NoError(t, err) resp := s.DoRaw("POST", targetEndpointURL, requestBytes, http.StatusOK) @@ -6727,6 +7050,179 @@ func (s *integrationMDMTestSuite) TestValidSyncMLRequestNoAuth() { require.True(t, s.isXMLTagContentPresent("Add", resSoapMsg)) } +func (s *integrationMDMTestSuite) TestBitLockerEnforcementNotifications() { + t := s.T() + ctx := context.Background() + windowsHost := createOrbitEnrolledHost(t, "windows", t.Name(), s.ds) + + checkNotification := func(want bool) { + resp := orbitGetConfigResponse{} + s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *windowsHost.OrbitNodeKey)), http.StatusOK, &resp) + require.Equal(t, want, resp.Notifications.EnforceBitLockerEncryption) + } + + // notification is false by default + checkNotification(false) + + // enroll the host into Fleet MDM + encodedBinToken, err := GetEncodedBinarySecurityToken(fleet.WindowsMDMProgrammaticEnrollmentType, *windowsHost.OrbitNodeKey) + require.NoError(t, err) + requestBytes, err := s.newSecurityTokenMsg(encodedBinToken, true, false) + require.NoError(t, err) + s.DoRaw("POST", microsoft_mdm.MDE2EnrollPath, requestBytes, http.StatusOK) + + // simulate osquery checking in and updating this info + // TODO: should we automatically fill these fields on MDM enrollment? + require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), windowsHost.ID, false, true, "https://example.com", true, fleet.WellKnownMDMFleet)) + + // notification is still false + checkNotification(false) + + // configure disk encryption for the global team + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": { "enable_disk_encryption": true } } }`), http.StatusOK, &acResp) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) + + // host still doesn't get the notification because we don't have disk + // encryption information yet. + checkNotification(false) + + // host has disk encryption off, gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, false)) + checkNotification(true) + + // host has disk encryption on, we don't have disk encryption info. Gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, true)) + checkNotification(true) + + // host has disk encryption on, we don't know if the key is decriptable. Gets the notification + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, windowsHost.ID, "test-key", "", nil) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the key is not decryptable by fleet. Gets the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, false, time.Now()) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the disk was encrypted by fleet. Doesn't get the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, true, time.Now()) + require.NoError(t, err) + checkNotification(false) + + // create a new team + tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name(), + Description: "desc", + }) + require.NoError(t, err) + // add the host to the team + err = s.ds.AddHostsToTeam(context.Background(), &tm.ID, []uint{windowsHost.ID}) + require.NoError(t, err) + + // notification is false now since the team doesn't have disk encryption enabled + checkNotification(false) + + // enable disk encryption on the team + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: tm.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(true), + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + + // host gets the notification + checkNotification(true) + + // host has disk encryption off, gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, false)) + checkNotification(true) + + // host has disk encryption on, we don't have disk encryption info. Gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, true)) + checkNotification(true) + + // host has disk encryption on, we don't know if the key is decriptable. Gets the notification + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, windowsHost.ID, "test-key", "", nil) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the key is not decryptable by fleet. Gets the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, false, time.Now()) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the disk was encrypted by fleet. Doesn't get the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, true, time.Now()) + require.NoError(t, err) + checkNotification(false) +} + +func (s *integrationMDMTestSuite) TestHostDiskEncryptionKey() { + t := s.T() + ctx := context.Background() + + host := createOrbitEnrolledHost(t, "windows", "h1", s.ds) + + // try to call the endpoint while the host is not MDM-enrolled + res := s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + EncryptionKey: []byte("WILL-FAIL"), + }, http.StatusBadRequest) + msg := extractServerErrorText(res.Body) + require.Contains(t, msg, "host is not enrolled with fleet") + + // mark it as enrolled in Fleet + err := s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet) + require.NoError(t, err) + + // set its encryption key + s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + EncryptionKey: []byte("ABC"), + }, http.StatusNoContent) + + hdek, err := s.ds.GetHostDiskEncryptionKey(ctx, host.ID) + require.NoError(t, err) + require.NotNil(t, hdek.Decryptable) + require.True(t, *hdek.Decryptable) + + // the key is encrypted the same way as the macOS keys (except with the WSTEP + // certificate), so it can be decrypted using the same decryption function. + wstepCert, _, _, err := s.fleetCfg.MDM.MicrosoftWSTEP() + require.NoError(t, err) + decrypted, err := servermdm.DecryptBase64CMS(hdek.Base64Encrypted, wstepCert.Leaf, wstepCert.PrivateKey) + require.NoError(t, err) + require.Equal(t, "ABC", string(decrypted)) + + // set it with a client error + s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + ClientError: "fail", + }, http.StatusNoContent) + + hdek, err = s.ds.GetHostDiskEncryptionKey(ctx, host.ID) + require.NoError(t, err) + require.Nil(t, hdek.Decryptable) + require.Empty(t, hdek.Base64Encrypted) + + // set a different key + s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + EncryptionKey: []byte("DEF"), + }, http.StatusNoContent) + + hdek, err = s.ds.GetHostDiskEncryptionKey(ctx, host.ID) + require.NoError(t, err) + require.NotNil(t, hdek.Decryptable) + require.True(t, *hdek.Decryptable) + + decrypted, err = servermdm.DecryptBase64CMS(hdek.Base64Encrypted, wstepCert.Leaf, wstepCert.PrivateKey) + require.NoError(t, err) + require.Equal(t, "DEF", string(decrypted)) +} + // /////////////////////////////////////////////////////////////////////////// // Common helpers @@ -6923,7 +7419,7 @@ func (s *integrationMDMTestSuite) newSecurityTokenMsg(encodedBinToken string, de } // TODO: Add support to add custom DeviceID when DeviceAuth is in place -func (s *integrationMDMTestSuite) newSyncMLSessionMsg(managementUrl string) ([]byte, error) { +func (s *integrationMDMTestSuite) newSyncMLSessionMsg(deviceID string, managementUrl string) ([]byte, error) { if len(managementUrl) == 0 { return nil, errors.New("managementUrl is empty") } @@ -6939,7 +7435,7 @@ func (s *integrationMDMTestSuite) newSyncMLSessionMsg(managementUrl string) ([]b ` + managementUrl + ` - DB257C3A08778F4FB61E2749066C1F27 + ` + deviceID + ` @@ -6963,7 +7459,7 @@ func (s *integrationMDMTestSuite) newSyncMLSessionMsg(managementUrl string) ([]b ./DevInfo/DevId - DB257C3A08778F4FB61E2749066C1F27 + ` + deviceID + ` diff --git a/server/service/mdm.go b/server/service/mdm.go index f0312e417785..7632eb4cf1d2 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -404,3 +404,61 @@ func (svc *Service) VerifyMDMWindowsConfigured(ctx context.Context) error { return nil } + +//////////////////////////////////////////////////////////////////////////////// +// Apple or Windows MDM Middleware +//////////////////////////////////////////////////////////////////////////////// + +func (svc *Service) VerifyMDMAppleOrWindowsConfigured(ctx context.Context) error { + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + // skipauth: Authorization is currently for user endpoints only. + svc.authz.SkipAuthorization(ctx) + return err + } + + // Apple or Windows MDM configuration setting + if !appCfg.MDM.EnabledAndConfigured && !appCfg.MDM.WindowsEnabledAndConfigured { + // skipauth: Authorization is currently for user endpoints only. + svc.authz.SkipAuthorization(ctx) + return fleet.ErrMDMNotConfigured + } + + return nil +} + +//////////////////////////////////////////////////////////////////////////////// +// GET /mdm/disk_encryption/summary +//////////////////////////////////////////////////////////////////////////////// + +type getMDMDiskEncryptionSummaryRequest struct { + TeamID *uint `query:"team_id,optional"` +} + +type getMDMDiskEncryptionSummaryResponse struct { + *fleet.MDMDiskEncryptionSummary + Err error `json:"error,omitempty"` +} + +func (r getMDMDiskEncryptionSummaryResponse) error() error { return r.Err } + +func getMDMDiskEncryptionSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getMDMDiskEncryptionSummaryRequest) + + des, err := svc.GetMDMDiskEncryptionSummary(ctx, req.TeamID) + if err != nil { + return getMDMDiskEncryptionSummaryResponse{Err: err}, nil + } + + return &getMDMDiskEncryptionSummaryResponse{ + MDMDiskEncryptionSummary: des, + }, nil +} + +func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*fleet.MDMDiskEncryptionSummary, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 13809587ecc5..f395d26a988d 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -15,8 +15,10 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/micromdm/scep/v2/cryptoutil/x509util" "github.com/stretchr/testify/require" @@ -111,6 +113,12 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // error retrieving app config authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -124,6 +132,12 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, testErr) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // mdm configured authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -135,9 +149,14 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { require.True(t, ds.AppConfigFuncInvoked) ds.AppConfigFuncInvoked = false require.False(t, authzCtx.Checked()) + + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.NoError(t, err) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.False(t, authzCtx.Checked()) } -// TODO: update this test with the correct config option func TestVerifyMDMWindowsConfigured(t *testing.T) { ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium} @@ -148,7 +167,7 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { authzCtx := &authz_ctx.AuthorizationContext{} ctx := authz_ctx.NewContext(baseCtx, authzCtx) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: false}}, nil + return &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: false}}, nil } err := svc.VerifyMDMWindowsConfigured(ctx) @@ -157,6 +176,12 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // error retrieving app config authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -171,6 +196,12 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, testErr) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // mdm configured authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -183,6 +214,12 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { require.True(t, ds.AppConfigFuncInvoked) ds.AppConfigFuncInvoked = false require.False(t, authzCtx.Checked()) + + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.NoError(t, err) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.False(t, authzCtx.Checked()) } func TestMicrosoftWSTEPConfig(t *testing.T) { @@ -195,7 +232,7 @@ func TestMicrosoftWSTEPConfig(t *testing.T) { ds.WSTEPStoreCertificateFunc = func(ctx context.Context, name string, crt *x509.Certificate) error { require.Equal(t, "test-client", name) require.Equal(t, "test-client", crt.Subject.CommonName) - require.Equal(t, "FleetDM", crt.Subject.OrganizationalUnit[0]) + require.Equal(t, "Fleet", crt.Subject.OrganizationalUnit[0]) return nil } @@ -251,5 +288,175 @@ func TestMicrosoftWSTEPConfig(t *testing.T) { parsedCert, err := x509.ParseCertificate(rawDER) require.NoError(t, err) require.Equal(t, "test-client", parsedCert.Subject.CommonName) - require.Equal(t, "FleetDM", parsedCert.Subject.OrganizationalUnit[0]) + require.Equal(t, "Fleet", parsedCert.Subject.OrganizationalUnit[0]) +} + +func TestMDMCommonAuthorization(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + ds.GetMDMAppleFileVaultSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) { + return &fleet.MDMAppleFileVaultSummary{}, nil + } + ds.GetMDMWindowsBitLockerSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + return &fleet.MDMWindowsBitLockerSummary{}, nil + } + + mockTeamFuncWithUser := func(u *fleet.User) mock.TeamFunc { + return func(ctx context.Context, teamID uint) (*fleet.Team, error) { + if len(u.Teams) > 0 { + for _, t := range u.Teams { + if t.ID == teamID { + return &fleet.Team{ID: teamID, Users: []fleet.TeamUser{{User: *u, Role: t.Role}}}, nil + } + } + } + return &fleet.Team{}, nil + } + } + + testCases := []struct { + name string + user *fleet.User + shouldFailGlobal bool + shouldFailTeam bool + }{ + { + "global admin", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + false, + }, + { + "global maintainer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + false, + false, + }, + { + "global observer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + true, + true, + }, + { + "team admin, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + true, + false, + }, + { + "team admin, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + true, + true, + }, + { + "team maintainer, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + true, + false, + }, + { + "team maintainer, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + true, + true, + }, + { + "team observer, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + true, + true, + }, + { + "team observer, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + true, + true, + }, + { + "user no roles", + &fleet.User{ID: 1337}, + true, + true, + }, + } + + checkShouldFail := func(err error, shouldFail bool) { + if !shouldFail { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), authz.ForbiddenErrorMessage) + } + } + + for _, tt := range testCases { + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + ds.TeamFunc = mockTeamFuncWithUser(tt.user) + + t.Run(tt.name, func(t *testing.T) { + // test authz get disk encryptions summary (no team) + _, err := svc.GetMDMDiskEncryptionSummary(ctx, nil) + checkShouldFail(err, tt.shouldFailGlobal) + + // test authz get disk encryptions summary (team 1) + _, err = svc.GetMDMDiskEncryptionSummary(ctx, ptr.Uint(1)) + checkShouldFail(err, tt.shouldFailTeam) + }) + } +} + +func TestGetMDMDiskEncryptionSummary(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license}) + + ctx = test.UserContext(ctx, test.UserAdmin) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.GetMDMAppleFileVaultSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) { + require.Nil(t, teamID) + return &fleet.MDMAppleFileVaultSummary{Verified: 1, Verifying: 2, ActionRequired: 3, Failed: 4, Enforcing: 5, RemovingEnforcement: 6}, nil + } + ds.GetMDMWindowsBitLockerSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + require.Nil(t, teamID) + // Use default zeros verifying, action_required, or removing_enforcement + return &fleet.MDMWindowsBitLockerSummary{Verified: 7, Failed: 8, Enforcing: 9}, nil + } + + // Test that the summary properly combines the results of the two methods + des, err := svc.GetMDMDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + require.NotNil(t, des) + require.Equal(t, *des, fleet.MDMDiskEncryptionSummary{ + Verified: fleet.MDMPlatformsCounts{ + MacOS: 1, + Windows: 7, + }, + Verifying: fleet.MDMPlatformsCounts{ + MacOS: 2, + Windows: 0, + }, + ActionRequired: fleet.MDMPlatformsCounts{ + MacOS: 3, + Windows: 0, + }, + Failed: fleet.MDMPlatformsCounts{ + MacOS: 4, + Windows: 8, + }, + Enforcing: fleet.MDMPlatformsCounts{ + MacOS: 5, + Windows: 9, + }, + RemovingEnforcement: fleet.MDMPlatformsCounts{ + MacOS: 6, + Windows: 0, + }, + }) } diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 071c5ca3b5bd..2ebaf1bfc738 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -13,6 +13,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strconv" "strings" "text/template" @@ -1184,6 +1185,29 @@ func (svc *Service) GetMDMWindowsTOSContent(ctx context.Context, redirectUri str return htmlBuf.String(), nil } +// isValidUPN checks if the provided user ID is a valid UPN +func isValidUPN(userID string) bool { + const upnRegex = `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + re := regexp.MustCompile(upnRegex) + return re.MatchString(userID) +} + +// isDeviceProgrammaticallyEnrolled checks if the device was enrolled through programmatic flow +func (svc *Service) isDeviceProgrammaticallyEnrolled(ctx context.Context, deviceID string) (bool, error) { + enrolledDevice, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) + + if err != nil || enrolledDevice == nil { + return false, errors.New("device not found") + } + + // If user identity is a MS-MDM UPN it means that the device was enrolled through user-driven flow + if isValidUPN(enrolledDevice.MDMEnrollUserID) { + return false, nil + } + + return true, nil +} + func (svc *Service) getManagementResponse(ctx context.Context, reqSyncML *fleet.SyncMLMessage) (*string, error) { if reqSyncML == nil { return nil, fleet.NewInvalidArgumentError("syncml req message", "message is not present") @@ -1212,9 +1236,15 @@ func (svc *Service) getManagementResponse(ctx context.Context, reqSyncML *fleet. return nil, err } + // Checking if the device was enrolled through programmatic flow + isProgrammaticEnrollment, err := svc.isDeviceProgrammaticallyEnrolled(ctx, deviceID) + if err != nil { + return nil, err + } + // Checking the SyncML message types var response string - if isSessionInitializationMessage(reqSyncML.Body) { + if isSessionInitializationMessage(reqSyncML.Body) && !isProgrammaticEnrollment { // Create response payload - MDM SyncML configuration profiles commands will be enforced here response = ` diff --git a/server/service/middleware/mdmconfigured/mdmconfigured.go b/server/service/middleware/mdmconfigured/mdmconfigured.go index be6b5ddc4637..75343ad65c57 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured.go @@ -17,6 +17,18 @@ func NewMDMConfigMiddleware(svc fleet.Service) *Middleware { return &Middleware{svc: svc} } +func (m *Middleware) VerifyAppleOrWindowsMDM() endpoint.Middleware { + return func(next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, req interface{}) (interface{}, error) { + if err := m.svc.VerifyMDMAppleOrWindowsConfigured(ctx); err != nil { + return nil, err + } + + return next(ctx, req) + } + } +} + func (m *Middleware) VerifyAppleMDM() endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req interface{}) (interface{}, error) { diff --git a/server/service/middleware/mdmconfigured/mdmconfigured_test.go b/server/service/middleware/mdmconfigured/mdmconfigured_test.go index 7ac09b6bf85c..2c6a01337f3c 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured_test.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured_test.go @@ -2,6 +2,7 @@ package mdmconfigured import ( "context" + "fmt" "sync/atomic" "testing" @@ -32,6 +33,13 @@ func (m *mockService) VerifyMDMWindowsConfigured(ctx context.Context) error { return nil } +func (m *mockService) VerifyMDMAppleOrWindowsConfigured(ctx context.Context) error { + if !m.mdmConfigured.Load() && !m.msMdmConfigured.Load() { + return fleet.ErrMDMNotConfigured + } + return nil +} + func TestMDMConfigured(t *testing.T) { svc := mockService{} svc.mdmConfigured.Store(true) @@ -99,3 +107,49 @@ func TestWindowsMDMNotConfigured(t *testing.T) { require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) require.False(t, nextCalled) } + +func TestAppleOrWindowsMDMConfigured(t *testing.T) { + svc := mockService{} + mw := NewMDMConfigMiddleware(&svc) + + cases := []struct { + apple bool + windows bool + }{ + {true, false}, + {false, true}, + {true, true}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("apple:%t;windows:%t", c.apple, c.windows), func(t *testing.T) { + svc.mdmConfigured.Store(c.apple) + svc.msMdmConfigured.Store(c.windows) + nextCalled := false + next := func(ctx context.Context, req interface{}) (interface{}, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyAppleOrWindowsMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.NoError(t, err) + require.True(t, nextCalled) + }) + } +} + +func TestAppleOrWindowsMDMNotConfigured(t *testing.T) { + svc := mockService{} + mw := NewMDMConfigMiddleware(&svc) + + nextCalled := false + next := func(ctx context.Context, req interface{}) (interface{}, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyAppleOrWindowsMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.False(t, nextCalled) +} diff --git a/server/service/orbit.go b/server/service/orbit.go index 28df989cd1b1..40a0bac9f93a 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -14,6 +14,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/kit/log/level" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" @@ -270,6 +271,12 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } + if config.IsMDMFeatureFlagEnabled() && + mdmConfig.EnableDiskEncryption && + host.IsEligibleForBitLockerEncryption() { + notifs.EnforceBitLockerEncryption = true + } + return fleet.OrbitConfig{ Flags: opts.CommandLineStartUpFlags, Extensions: extensionsFiltered, @@ -300,6 +307,13 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } + if appConfig.MDM.WindowsEnabledAndConfigured && + config.IsMDMFeatureFlagEnabled() && + appConfig.MDM.EnableDiskEncryption.Value && + host.IsEligibleForBitLockerEncryption() { + notifs.EnforceBitLockerEncryption = true + } + return fleet.OrbitConfig{ Flags: opts.CommandLineStartUpFlags, Extensions: extensionsFiltered, @@ -503,3 +517,82 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host return fleet.ErrMissingLicense } + +///////////////////////////////////////////////////////////////////////////////// +// Post Orbit disk encryption key +///////////////////////////////////////////////////////////////////////////////// + +type orbitPostDiskEncryptionKeyRequest struct { + OrbitNodeKey string `json:"orbit_node_key"` + EncryptionKey []byte `json:"encryption_key"` + ClientError string `json:"client_error"` +} + +// interface implementation required by the OrbitClient +func (r *orbitPostDiskEncryptionKeyRequest) setOrbitNodeKey(nodeKey string) { + r.OrbitNodeKey = nodeKey +} + +// interface implementation required by orbit authentication +func (r *orbitPostDiskEncryptionKeyRequest) orbitHostNodeKey() string { + return r.OrbitNodeKey +} + +type orbitPostDiskEncryptionKeyResponse struct { + Err error `json:"error,omitempty"` +} + +func (r orbitPostDiskEncryptionKeyResponse) error() error { return r.Err } +func (r orbitPostDiskEncryptionKeyResponse) Status() int { return http.StatusNoContent } + +func postOrbitDiskEncryptionKeyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*orbitPostDiskEncryptionKeyRequest) + if err := svc.SetOrUpdateDiskEncryptionKey(ctx, string(req.EncryptionKey), req.ClientError); err != nil { + return orbitPostDiskEncryptionKeyResponse{Err: err}, nil + } + return orbitPostDiskEncryptionKeyResponse{}, nil +} + +func (svc *Service) SetOrUpdateDiskEncryptionKey(ctx context.Context, encryptionKey, clientError string) error { + // this is not a user-authenticated endpoint + svc.authz.SkipAuthorization(ctx) + + host, ok := hostctx.FromContext(ctx) + if !ok { + return newOsqueryError("internal error: missing host from request context") + } + if !host.MDMInfo.IsFleetEnrolled() { + return badRequest("host is not enrolled with fleet") + } + + var ( + encryptedEncryptionKey string + decryptable *bool + ) + + // only set the encryption key if there was no client error + if clientError == "" && encryptionKey != "" { + wstepCert, _, _, err := svc.config.MDM.MicrosoftWSTEP() + if err != nil { + // should never return an error because the WSTEP is first parsed and + // cached at the start of the fleet serve process. + return ctxerr.Wrap(ctx, err, "get WSTEP certificate") + } + enc, err := microsoft_mdm.Encrypt(encryptionKey, wstepCert.Leaf) + if err != nil { + return ctxerr.Wrap(ctx, err, "encrypt the key with WSTEP certificate") + } + encryptedEncryptionKey = enc + decryptable = ptr.Bool(true) + } + + if err := svc.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, encryptedEncryptionKey, clientError, decryptable); err != nil { + return ctxerr.Wrap(ctx, err, "set or update disk encryption key") + } + if encryptedEncryptionKey != "" { + if err := svc.ds.SetOrUpdateHostDisksEncryption(ctx, host.ID, true); err != nil { + return ctxerr.Wrap(ctx, err, "set or update host disks encryption") + } + } + return nil +} diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index b4a8ca3e6bb7..b7374a58f21b 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -338,3 +338,18 @@ func OrbitRetryInterval() time.Duration { } return constant.OrbitEnrollRetrySleep } + +// SetOrUpdateDiskEncryptionKey sends a request to the server to set or update the disk +// encryption keys and result of the encryption process +func (oc *OrbitClient) SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error { + verb, path := "POST", "/api/fleet/orbit/disk_encryption_key" + + var resp orbitPostDiskEncryptionKeyResponse + if err := oc.authenticatedRequest(verb, path, &orbitPostDiskEncryptionKeyRequest{ + EncryptionKey: diskEncryptionStatus.EncryptionKey, + ClientError: diskEncryptionStatus.ClientError, + }, &resp); err != nil { + return err + } + return nil +} diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 9887a1243dda..165b7e2cc7eb 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -446,7 +446,7 @@ var extraDetailQueries = map[string]DetailQuery{ ) UNION ALL SELECT * FROM ( - SELECT "is_federated" AS "key", data as "value" FROM registry + SELECT "is_federated" AS "key", data as "value" FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\Software\Microsoft\Enrollments\%\IsFederated' LIMIT 1 ) @@ -609,7 +609,7 @@ var mdmQueries = map[string]DetailQuery{ // [1]: https://developer.apple.com/documentation/devicemanagement/fderecoverykeyescrow "mdm_disk_encryption_key_file_lines_darwin": { Query: fmt.Sprintf(` - WITH + WITH de AS (SELECT IFNULL((%s), 0) as encrypted), fl AS (SELECT line FROM file_lines WHERE path = '/var/db/FileVaultPRK.dat') SELECT encrypted, hex(line) as hex_line FROM de LEFT JOIN fl;`, usesMacOSDiskEncryptionQuery), @@ -1460,7 +1460,7 @@ func directIngestDiskEncryptionKeyFileDarwin( // it's okay if the key comes empty, this can happen and if the disk is // encrypted it means we need to reset the encryption key - return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, rows[0]["filevault_key"]) + return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, rows[0]["filevault_key"], "", nil) } // directIngestDiskEncryptionKeyFileLinesDarwin ingests the FileVault key from the `file_lines` @@ -1511,7 +1511,7 @@ func directIngestDiskEncryptionKeyFileLinesDarwin( // it's okay if the key comes empty, this can happen and if the disk is // encrypted it means we need to reset the encryption key - return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64.StdEncoding.EncodeToString(b)) + return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64.StdEncoding.EncodeToString(b), "", nil) } func directIngestMacOSProfiles( diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 2c9e3d60ee85..6e4224625922 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -1130,7 +1130,7 @@ func TestDirectIngestDiskEncryptionKeyDarwin(t *testing.T) { } } - ds.SetOrUpdateHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint, encryptedBase64Key string) error { + ds.SetOrUpdateHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint, encryptedBase64Key, clientError string, decryptable *bool) error { if base64.StdEncoding.EncodeToString([]byte(wantKey)) != encryptedBase64Key { return errors.New("key mismatch") } diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index cfd3365aacc4..7b90c43c0f03 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -586,7 +586,7 @@ func mockSuccessfulPush(pushes []*mdm.Push) (map[string]*push.Response, error) { return res, nil } -func mdmAppleConfigurationRequiredEndpoints() []struct { +func mdmConfigurationRequiredEndpoints() []struct { method, path string deviceAuthenticated bool premiumOnly bool @@ -630,6 +630,8 @@ func mdmAppleConfigurationRequiredEndpoints() []struct { {"POST", "/api/latest/fleet/device/%s/migrate_mdm", true, true}, {"POST", "/api/latest/fleet/mdm/apple/profiles/preassign", false, true}, {"POST", "/api/latest/fleet/mdm/apple/profiles/match", false, true}, + {"POST", "/api/fleet/orbit/disk_encryption_key", false, false}, + {"GET", "/api/latest/fleet/mdm/disk_encryption/summary", false, true}, } } diff --git a/server/service/transport.go b/server/service/transport.go index c9caee032b0b..4170695e11af 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -317,9 +317,9 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error) } macOSSettingsStatus := r.URL.Query().Get("macos_settings") - switch fleet.MacOSSettingsStatus(macOSSettingsStatus) { - case fleet.MacOSSettingsFailed, fleet.MacOSSettingsPending, fleet.MacOSSettingsVerifying, fleet.MacOSSettingsVerified: - hopt.MacOSSettingsFilter = fleet.MacOSSettingsStatus(macOSSettingsStatus) + switch fleet.OSSettingsStatus(macOSSettingsStatus) { + case fleet.OSSettingsFailed, fleet.OSSettingsPending, fleet.OSSettingsVerifying, fleet.OSSettingsVerified: + hopt.MacOSSettingsFilter = fleet.OSSettingsStatus(macOSSettingsStatus) case "": // No error when unset default: @@ -342,6 +342,32 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error) return hopt, ctxerr.Errorf(r.Context(), "invalid macos_settings_disk_encryption status %s", macOSSettingsDiskEncryptionStatus) } + osSettingsStatus := r.URL.Query().Get("os_settings") + switch fleet.OSSettingsStatus(osSettingsStatus) { + case fleet.OSSettingsFailed, fleet.OSSettingsPending, fleet.OSSettingsVerifying, fleet.OSSettingsVerified: + hopt.OSSettingsFilter = fleet.OSSettingsStatus(osSettingsStatus) + case "": + // No error when unset + default: + return hopt, ctxerr.Errorf(r.Context(), "invalid os_settings status %s", osSettingsStatus) + } + + osSettingsDiskEncryptionStatus := r.URL.Query().Get("os_settings_disk_encryption") + switch fleet.DiskEncryptionStatus(osSettingsDiskEncryptionStatus) { + case + fleet.DiskEncryptionVerifying, + fleet.DiskEncryptionVerified, + fleet.DiskEncryptionActionRequired, + fleet.DiskEncryptionEnforcing, + fleet.DiskEncryptionFailed, + fleet.DiskEncryptionRemovingEnforcement: + hopt.OSSettingsDiskEncryptionFilter = fleet.DiskEncryptionStatus(osSettingsDiskEncryptionStatus) + case "": + // No error when unset + default: + return hopt, ctxerr.Errorf(r.Context(), "invalid os_settings_disk_encryption status %s", macOSSettingsDiskEncryptionStatus) + } + mdmBootstrapPackageStatus := r.URL.Query().Get("bootstrap_package") switch fleet.MDMBootstrapPackageStatus(mdmBootstrapPackageStatus) { case fleet.MDMBootstrapPackageFailed, fleet.MDMBootstrapPackagePending, fleet.MDMBootstrapPackageInstalled: diff --git a/website/api/controllers/webhooks/receive-from-stripe.js b/website/api/controllers/webhooks/receive-from-stripe.js index 4b8a9e11386b..0af2abd1685e 100644 --- a/website/api/controllers/webhooks/receive-from-stripe.js +++ b/website/api/controllers/webhooks/receive-from-stripe.js @@ -69,10 +69,14 @@ module.exports = { let subscriptionForThisEvent = await Subscription.findOne({stripeSubscriptionId: subscriptionIdToFind}).populate('user'); let STRIPE_EVENTS_SENT_BEFORE_A_SUBSCRIPTION_RECORD_EXISTS = [ - 'invoice.created', - 'invoice.finalized', - 'invoice.paid', - 'invoice.payment_succeeded', + 'invoice.created',// Sent when a user submits the billing form on /customers/new-license, before the user's biliing card is charged. + 'invoice.finalized',// Sent when a user submits the billing form on /customers/new-license, before the user's biliing card is charged. + 'invoice.paid',//Sent when a user submits the billing form on /customers/new-license, when the user's biliing card is charged. + 'invoice.payment_succeeded',// Sent when payment for a users subscription is successful. The save-billing-info-and-subscribe action will check for this event before creating a license key. + 'invoice.payment_failed',// Sent when a users subscritpion payment fails. This can happen before we create a license key and save the subscription in the database. + 'invoice.payment_action_required',// Sent when a user's billing card requires additional verification from stripe. + 'invoice.updated',// Sent before an incomplete invoice is voided. (~24 hours after a payment fails) + 'invoice.voided',// Sent when an incomplete invoice is marked as voided. (~24 hours after a payment fails) ]; // If this event is for a subscription that was just created, we won't have a matching Subscription record in the database. This is because we wait until the subscription's invoice is paid to create the record in our database. diff --git a/website/assets/images/device-management-transparency-438x373@2x.png b/website/assets/images/device-management-transparency-438x373@2x.png new file mode 100644 index 000000000000..b8caf6252131 Binary files /dev/null and b/website/assets/images/device-management-transparency-438x373@2x.png differ diff --git a/website/assets/images/homepage-hero-background-1921x555@2x.png b/website/assets/images/homepage-hero-background-1921x555@2x.png deleted file mode 100644 index 763e548de05f..000000000000 Binary files a/website/assets/images/homepage-hero-background-1921x555@2x.png and /dev/null differ diff --git a/website/assets/images/homepage-hero-background-3840x500@2x.png b/website/assets/images/homepage-hero-background-3840x500@2x.png new file mode 100644 index 000000000000..1ca26bed15b5 Binary files /dev/null and b/website/assets/images/homepage-hero-background-3840x500@2x.png differ diff --git a/website/assets/images/icon-checkmark-circle-green-16x16@2x.png b/website/assets/images/icon-checkmark-circle-green-16x16@2x.png new file mode 100644 index 000000000000..94f8b26a5719 Binary files /dev/null and b/website/assets/images/icon-checkmark-circle-green-16x16@2x.png differ diff --git a/website/assets/images/logo-deloitte-166x36@2x.png b/website/assets/images/logo-deloitte-166x36@2x.png new file mode 100644 index 000000000000..b8d0777dc740 Binary files /dev/null and b/website/assets/images/logo-deloitte-166x36@2x.png differ diff --git a/website/assets/js/components/scrollable-tweets.component.js b/website/assets/js/components/scrollable-tweets.component.js index ed7030fdff9a..f40a2e9f57c8 100644 --- a/website/assets/js/components/scrollable-tweets.component.js +++ b/website/assets/js/components/scrollable-tweets.component.js @@ -20,7 +20,7 @@ parasails.registerComponent('scrollableTweets', { data: function () { return { currentTweetPage: 0, - numberOfTweetCards: 6, + numberOfTweetCards: 7, numberOfTweetPages: 0, numberOfTweetsPerPage: 0, tweetCardWidth: 0, @@ -110,6 +110,19 @@ parasails.registerComponent('scrollableTweets', {
+ +
+
+ Deloitte logo +
+

One of the best teams out there to go work for and help shape security platforms.

+
+
+

Dhruv Majumdar

+

Director Of Cyber Risk & Advisory @Deloitte

+
+
+